Merge remote-tracking branch 'origin/main' into brennanb2025/agent-status-preserve-restart

# Conflicts:
#	src/main/index.ts
#	src/renderer/src/hooks/useIpcEvents.test.ts
#	src/renderer/src/hooks/useIpcEvents.ts
This commit is contained in:
brennanb2025
2026-05-10 18:02:14 -07:00
492 changed files with 42568 additions and 10932 deletions
+9 -6
View File
@@ -310,13 +310,16 @@ jobs:
# already exist — that's a real conflict (someone tagged manually
# over a shipped version) and needs human attention.
if git rev-parse "v$new" >/dev/null 2>&1; then
# Use the REST API, not `gh release view`: the latter returns
# "release not found" for drafts *and* for absent releases,
# which would cause us to "recover" over a draft that a human
# is preparing. The API distinguishes them.
release_state="$(gh api \
# Why: use the REST API, not `gh release view`. `gh api` returns
# `true`/`false` for a real release, while a 404 means the tag is
# orphaned and can be safely re-dispatched. Capture failures
# explicitly so the 404 payload never leaks into the state check.
release_state="missing"
if gh api \
"repos/$GITHUB_REPOSITORY/releases/tags/v$new" \
--jq '.draft' 2>/dev/null || echo "missing")"
--jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then
release_state="$(cat "$RUNNER_TEMP/release-state")"
fi
case "$release_state" in
missing|true)
echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag."
-1
View File
@@ -7,7 +7,6 @@
"rules": {
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/jsx-uses-vars": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
+4
View File
@@ -1,5 +1,9 @@
# AGENTS.md
## Design System
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Code Comments: Document the "Why", Briefly
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
+264 -5
View File
@@ -74,17 +74,276 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..c17decbb7bc06f10d7b5d26317bd68fdc8c4e339 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -778,8 +778,8 @@ done:
posix_spawn_file_actions_destroy(&acts);
posix_spawnattr_destroy(&attrs);
@@ -23,7 +23,9 @@
#include <errno.h>
#include <string.h>
#include <stdlib.h>
+#include <stdio.h>
#include <unistd.h>
+#include <string>
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
+ int errnum;
+ std::string detail_name;
+ std::string detail_value;
+};
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error&);
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
- int err = -1;
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
- if (err != 0) {
- throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
+ pty_spawn_error spawn_error = { NULL, 0, "", "" };
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error);
+ if (spawn_error.errnum != 0) {
+ std::string spawn_message = pty_format_spawn_error(spawn_error);
+ throw Napi::Error::New(napiEnv, spawn_message);
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
+ switch (errnum) {
+ case E2BIG: return "E2BIG";
+ case EACCES: return "EACCES";
+ case EAGAIN: return "EAGAIN";
+ case EMFILE: return "EMFILE";
+ case ENFILE: return "ENFILE";
+ case ENOENT: return "ENOENT";
+ case ENOMEM: return "ENOMEM";
+ default: return "errno";
+ }
+}
+
+static void
+pty_set_spawn_error(pty_spawn_error* err,
+ const char* step,
+ int errnum,
+ const char* detail_name = NULL,
+ const char* detail_value = NULL) {
+ err->step = step;
+ err->errnum = errnum;
+ err->detail_name = detail_name ? detail_name : "";
+ err->detail_value = detail_value ? detail_value : "";
+}
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error& err) {
+ char errno_buf[64];
+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum);
+
+ std::string message = "node-pty: ";
+ message += err.step ? err.step : "unknown";
+ message += " failed: ";
+ message += pty_errno_name(err.errnum);
+ message += " (errno ";
+ message += errno_buf;
+ message += ", ";
+ message += strerror(err.errnum);
+ message += ")";
+
+ if (!err.detail_name.empty()) {
+ message += " - ";
+ message += err.detail_name;
+ message += "='";
+ message += err.detail_value;
+ message += "'";
+ }
+
+ return message;
+}
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err) {
- int low_fds[3];
+ pty_spawn_error* err) {
+ int low_fds[3] = {-1, -1, -1};
size_t count = 0;
+ int res = -1;
+ int slave = -1;
+ posix_spawn_file_actions_t acts;
+ bool acts_initialized = false;
+ posix_spawnattr_t attrs;
+ bool attrs_initialized = false;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
- int slave;
char slave_pty_name[128];
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
+ goto done;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
- return;
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
+ goto done;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name);
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name);
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res);
+ goto done;
+ }
+ acts_initialized = true;
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
- posix_spawnattr_t attrs;
- posix_spawnattr_init(&attrs);
- *err = posix_spawnattr_setflags(&attrs, flags);
- if (*err != 0) {
+ res = posix_spawnattr_init(&attrs);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_init", res);
+ goto done;
+ }
+ attrs_initialized = true;
+ res = posix_spawnattr_setflags(&attrs, flags);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigmask(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
goto done;
}
do
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
- while (*err == EINTR);
+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
+ while (res == EINTR);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]);
+ }
done:
- posix_spawn_file_actions_destroy(&acts);
- posix_spawnattr_destroy(&attrs);
+ if (acts_initialized) {
+ posix_spawn_file_actions_destroy(&acts);
+ }
+ if (attrs_initialized) {
+ posix_spawnattr_destroy(&attrs);
+ }
+ if (slave != -1) {
+ close(slave);
+ }
+ if (err->errnum != 0 && *master != -1) {
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
+ close(low_fds[i]);
+ if (low_fds[i] != -1) {
+ close(low_fds[i]);
+ }
}
}
#endif
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
# like a first-time install (onboarding overlay paints, no persisted repos,
# no saved sessions). Your real `orca-dev` profile is left untouched.
#
# Usage:
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
set -euo pipefail
KEEP=0
for arg in "$@"; do
case "$arg" in
--keep) KEEP=1 ;;
-h|--help)
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--keep] [--help]" >&2
exit 2 ;;
esac
done
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
mkdir -p "$PROFILE_DIR"
cleanup() {
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
rm -rf "$PROFILE_DIR"
echo "[dev-fresh-profile] removed $PROFILE_DIR"
else
echo "[dev-fresh-profile] kept $PROFILE_DIR"
fi
}
trap cleanup EXIT
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev
+79 -1
View File
@@ -1,5 +1,7 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -61,7 +63,83 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
const electronViteCli =
process.env.ORCA_ELECTRON_VITE_CLI ||
path.join(path.dirname(require.resolve('electron-vite/package.json')), 'bin', 'electron-vite.js')
const forwardedArgs = ['dev', ...process.argv.slice(2)]
// Why: every `pn dev` should be attachable from agent-browser/playwright-cli
// without manual port juggling. Pick a best-effort deterministic port per
// worktree; falls back to a probe sweep if the deterministic pick or its
// neighbors are busy (multiple worktrees may share a machine).
const forwardedRaw = process.argv.slice(2)
function isPortFree(port) {
return new Promise((resolve) => {
const srv = net.createServer()
srv.once('error', () => {
// Why: error fires before listen binds; close() may throw — swallow it
// so the handle is released without leaking listeners across 64 probes.
try {
srv.close()
} catch {}
resolve(false)
})
srv.once('listening', () => srv.close(() => resolve(true)))
srv.listen(port, '127.0.0.1')
})
}
async function pickDebugPort() {
// Why: 32 bits of SHA1 (vs 16) reduces truncation bias; modulo 200 still
// collides routinely across many worktrees, hence the probe sweep below.
const seed = parseInt(createHash('sha1').update(repoRoot).digest('hex').slice(0, 8), 16)
const base = 9333 + (seed % 200) // deterministic base in 9333..9532; probe sweeps up to base+63
for (let i = 0; i < 64; i++) {
const p = base + i
if (await isPortFree(p)) {
return p
}
}
return null
}
function parseDebugPortEnv(raw) {
const n = Number.parseInt(raw, 10)
if (!Number.isInteger(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) {
return null
}
return n
}
// Why: exact match (or `=` form) avoids false positives on hypothetical
// `--remote-debugging-port-*` flags; the bare flag also covers the
// space-separated form. `--remote-debugging-pipe` opts into pipe-based
// debugging — don't fight the user's choice by injecting a port.
const userPassedPort = forwardedRaw.some(
(a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe'
)
// Why: --help/--version exit immediately; binding a probe socket and printing
// a debug-port line would be noise.
const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
let forwardedExtras = []
if (!userPassedPort && !isHelpOrVersion) {
const envPortRaw = process.env.REMOTE_DEBUGGING_PORT
let port = null
if (envPortRaw) {
port = parseDebugPortEnv(envPortRaw)
if (port === null) {
console.error(
`[orca-dev] Ignoring invalid REMOTE_DEBUGGING_PORT=${JSON.stringify(envPortRaw)}; falling back to probe.`
)
}
}
if (port === null) {
port = await pickDebugPort()
}
if (port !== null) {
forwardedExtras = [`--remote-debugging-port=${port}`]
// Why: stderr keeps stdout clean for downstream parsing; log uses
// 127.0.0.1 to match the interface we actually probed (localhost may
// resolve to ::1 on IPv6-first hosts).
console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
} else {
console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.')
}
}
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
stdio: 'inherit',
env: process.env,
+277
View File
@@ -0,0 +1,277 @@
# Orca UI Style Guide
This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is *not* an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the *roles and rules* for using them.
## Overview
Orca is an Electron desktop app for orchestrating coding agents across git worktrees. The visual identity is **monochrome and quiet** — neutral grays carry the chrome, color is reserved for state (selection ring, destructive, git decorations). The product spends most of its time hosting other people's tools (Monaco, xterm, Markdown previews), so Orca's own UI should recede and frame.
When in doubt:
- Reach for **muted/accent/border** before reaching for color.
- Reach for **CSS variables** before hardcoding hex.
- Match the nearest **shadcn primitive** before writing custom CSS.
## Source of truth
| Concern | Canonical location |
| -------------------- | ------------------------------------------------ |
| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) |
| Tailwind theme bindings | Same file, `@theme inline { … }` block |
| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) |
| App typography / scrollbars / titlebar chrome | Same `main.css` |
Never hardcode a hex value in component code if a variable already covers it. If a new token is needed, add it to `main.css` (both `:root` and `.dark`), expose it in the `@theme inline` block, then use it.
## Color roles
Tokens come in pairs: a **surface** and a **foreground** that meets contrast on it. Always use them together.
| Role | Use it for | Don't use it for |
| ---------------------------------- | ------------------------------------------------------------- | --------------------------------------------- |
| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) |
| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself |
| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI |
| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions |
| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action |
| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions |
| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) |
| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) |
| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` |
| `input` | Form field background only | Anywhere outside form fields |
| `ring` | Focus-visible outlines, active selection halos | Persistent decoration |
| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels |
| `editor-surface` | Background of Monaco / markdown editor panes | App chrome |
The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, and `--sidebar-ring` — use them inside the worktree sidebar so its hover/selected/focus states stay consistent and don't bleed into other panels. `editor-surface` is its own token (not just `background`) because Monaco and the markdown editor have a slightly darker surface in dark mode to match VS Code conventions; reach for it whenever you're rendering an editor pane.
### Git decoration colors
For diff status, file-tree decorations, and the changes view, use the git decoration tokens (mirroring VS Code's palette so users transferring from VS Code aren't surprised):
| Token | State |
| ------------------------------ | ------------------------------ |
| `--git-decoration-added` | Added / new |
| `--git-decoration-modified` | Modified |
| `--git-decoration-deleted` | Deleted |
| `--git-decoration-renamed` | Renamed |
| `--git-decoration-untracked` | Untracked |
| `--git-decoration-copied` | Copied |
Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention.
### List rows: hover, selected, current
A common point of drift. Use these conventions for any list-style row (worktrees, command palette items, settings nav):
- **Idle:** transparent background.
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, *plus* a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
### Color mixing
When you need a tint (e.g. a 12% primary wash on hover), use `color-mix` against the existing token, not a new hex:
```css
background: color-mix(in srgb, var(--primary) 12%, var(--background));
```
This keeps light/dark parity automatic.
## Typography
- **Family:** `Geist` is loaded as a single variable woff2 (weight range 100–900). Always reach for `Geist` for sans, never `Inter` or system sans.
- **Mono:** `var(--font-mono)` — used for paths, terminal-adjacent UI, code, and anywhere monospace conveys "this is literal."
- **Body letter-spacing:** `0.01em` (set globally on `body`). Don't override per component.
- **Sizes:** Tailwind's default scale. Common sizes in this repo:
- 11px (uppercase meta, sidebar headers, captions) — pair with `font-weight: 600` and `text-transform: uppercase` and `letter-spacing: 0.05em` for category labels.
- 12px (sub-text, paths, secondary content)
- 13px (sidebar items, dense list rows)
- 14px (default body, button text in `default` size)
## Radius
`--radius: 0.625rem` (10px) is the base; the rest are computed (`--radius-sm` = 0.6×, `--radius-md` = 0.8×, `--radius-lg` = 1×, `--radius-xl` = 1.4×, etc.). Buttons and inputs use `rounded-md`; the `Card` primitive uses `rounded-xl`; badges use `rounded-full`. Match the existing primitive's radius rather than introducing a new one.
## Elevation & shadows
Orca uses shadows sparingly. Three levels in practice:
1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level.
2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards.
3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved.
Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead.
## Components
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Use `cn()` for class merging. Pass user `className` last so callers can override.
- Use `class-variance-authority` (CVA) for variants when there are multiple.
### Buttons (`button.tsx`)
Variants in priority order:
| Variant | Use case |
| ------------- | ---------------------------------------------------------------- |
| `default` | The single affirmative action in a flow. |
| `secondary` | Lower-emphasis sibling next to a `default`. |
| `outline` | Toolbar / standalone actions where a filled button feels heavy. |
| `ghost` | Icon buttons, list-row triggers, anywhere chrome should disappear. |
| `link` | Inline text actions inside paragraphs. |
| `destructive` | Delete, discard, irreversible. Never for Cancel. |
Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `icon-xs`, `icon-sm`, `icon-lg`. Match the size to the surrounding row height — don't drop a `default` button into a 28px toolbar.
### Other primitives in this repo
Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive — exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper.
### Picking the right primitive
When a control has multiple plausible primitives, use this fork:
| You want… | Reach for | Don't use |
| -------------------------------------------------------- | -------------------------- | ---------------------------------------- |
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
### Tooltips
Tooltips exist to *name* a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI.
- **Use a tooltip when:** an icon-only button or compact chip needs a label. Toolbar icons, badges with abbreviations, truncated paths.
- **Don't use a tooltip when:** the control already has a visible label, the content is interactive (links, buttons), or the message is critical (errors, blocking warnings — those go inline).
- **Mounting:** the global `<TooltipProvider delayDuration={400}>` lives at the App root. Don't nest a second `TooltipProvider` unless you need a different delay for a tightly-scoped surface.
- **Trigger pattern:** wrap the trigger element with `<TooltipTrigger asChild>` so the tooltip's accessibility props attach to the button (not a wrapper span). This is required for keyboard focus to surface the tooltip.
- **Placement:** default `side="top" sideOffset={4}` — match the toolbar pattern in `sidebar/SidebarToolbar.tsx`. Pick a different side only when the default would clip against the viewport.
- **Shortcut chips inside tooltips:** if the action has a keyboard shortcut, append `<ShortcutKeyCombo />` rather than baking the keys into the label string. The chips render correctly per platform; baked-in strings drift.
```tsx
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={openSettings}>
<Settings />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>Settings</TooltipContent>
</Tooltip>
```
### Icons
Icons come from **`lucide-react`**. Don't import a second icon library.
- **Default size:** `size-4` (16px). `Button` auto-applies this to any `<svg>` it contains via `[&_svg:not([class*='size-'])]:size-4`, so most call sites don't need to set a size on the icon.
- **`size-3` / `size-3.5`:** for metadata, captions, and dense list rows where 16px is too loud.
- **`size-7`+:** for featured/empty-state hero icons only.
- **Stroke width:** lucide's default 2px. Don't override per-icon.
- **Color:** inherit from surrounding text — `text-muted-foreground` for secondary, `text-destructive` for destructive, etc. Don't apply a token to the SVG directly when the parent already carries the right color.
- **Spinner:** the canonical loading icon is `<Loader2 className="size-4 animate-spin" />`. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See *UX rule 1*.
### Keyboard shortcut chips
Use **`<ShortcutKeyCombo />`** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the *caller* picks the platform-appropriate labels and passes them in:
```tsx
const isMac = navigator.userAgent.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl'
const shift = isMac ? '⇧' : 'Shift'
<ShortcutKeyCombo keys={[mod, shift, 'N']} />
```
See `Landing.tsx` for the canonical pattern. Don't roll a one-off `<kbd>` — kbd chips drift in shape and color across the app fast if everyone styles their own.
**Where shortcuts surface in the UI:**
- **Tooltips on icon buttons** — append the chip after the label, trailing.
- **Dropdown / context-menu items** — use `<DropdownMenuShortcut>` (or its context-menu equivalent) for the right-aligned chip; don't position one yourself.
- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see *UX rule 3*.
**The label MUST match the actual binding for the platform.** If the keyboard handler reads `metaKey` on Mac and `ctrlKey` elsewhere, the chip must show `⌘` on Mac and `Ctrl` elsewhere. Mismatched chips are worse than no chip.
### Form anatomy
The pattern in `src/renderer/src/components/settings/SettingsFormControls.tsx` is the house style for any label + control + helper text. Match it for new forms:
- **Outer stack:** `space-y-3` for full-section forms (`ThemePicker`); `space-y-2` for compact single-control fields (`ColorField`, `NumberField`). Pick by density, not preference.
- **Label group:** `space-y-1` containing `<Label>` and a description in `text-xs text-muted-foreground`.
- **Control:** the shadcn primitive (`<Input>`, `<Select>`, etc.). Errors surface via `aria-invalid`; the input primitive already maps that to a destructive ring — don't paint your own.
- **Trailing metadata:** `text-[11px] text-muted-foreground` below the control (e.g., "Current: 14px · Default: 13px"), not next to the label.
### Scrollbars
Three scrollbar classes are defined globally in `main.css`:
- **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover.
- **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces.
- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still.
Apply one of these to overflow containers; don't write a fourth style.
## UX rules
These are the rules a contributor will most often get wrong if they're working in isolation. They apply to every UI change.
### 1. Match in-flight feedback to perceived duration
The right question isn't *"should this control change while it's working?"* — it's *"how long does the action take, and what does the user need to know during that time?"*
| Duration | Feedback |
| ------------------- | ------------------------------------------------------- |
| 0–100 ms | None. Anything visible reads as a glitch. |
| 100 ms–1 s | Disabled state only. |
| 1 s–3 s | Disabled + spinner or label swap. |
| 3 s+ or multi-step | Stage labels, progress, optional reassurance. |
Two corollaries:
- **Pre-reserve any space you'll later occupy.** If a control may swap to a longer label or grow an icon, fix its footprint up front (use `width`, not `min-width`). A control that resizes mid-action looks broken even when the action succeeded.
- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the *disabled* state immediately (so double-clicks don't double-submit) and the *visible* state on a timer.
### 2. Look for sibling components before designing in isolation
If your component has a sibling — same domain, overlapping behavior, often visible at adjacent moments in the same flow — the two should read as one design. Same icons, same shortcut conventions, same submit semantics. A user moving between them shouldn't perceive a seam.
This is *not* "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about *adjacent* components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it).
When there's no sibling, match the surrounding chrome — button sizes, icon weights, copy tone — and don't manufacture a sibling from a screen the user will never correlate with this one.
### 3. Don't overload the back-out path
`destructive` is for actions that lose data or can't be undone. **Cancel, Dismiss, Close, and Discard are not destructive** — they back the user out of an in-progress action and should stay quiet (default ghost button, no color, no keyboard chip, no animated affordance). Save the visual weight for the affirmative action so the two don't compete. Keyboard handlers can still honor Esc; the visible decoration is what stays minimal.
## Cross-platform
Orca runs on macOS, Linux, and Windows. Every UI change must hold up on all three, in both light and dark mode.
- **Modifier keys:** Never hardcode `e.metaKey`. Use `navigator.userAgent.includes('Mac')` to choose `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels:** Display `⌘` / `⇧` on Mac; display `Ctrl+` / `Shift+` on other platforms. The label must reflect the actual binding for that platform.
- **Window chrome:** macOS shows traffic lights; the titlebar reserves an 80px gutter (`titlebar-traffic-light-pad`) so they don't overlap content. Don't put hit targets in that band on Mac.
- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See *UX rules → 1*.
## When this guide is silent
If you have a UI question this doc doesn't answer:
1. Look at adjacent code in `src/renderer/src/components/` for the closest sibling, and follow its lead.
2. Check `src/renderer/src/components/ui/` for a primitive that already encodes the pattern.
3. If it's a token question, `main.css` is canonical — use what's there, or add a new one in both light and dark.
4. If none of those resolve it, ask the user before inventing.
+22 -40
View File
@@ -2,11 +2,11 @@
## Goal
When a user has the experimental agent dashboard toggle on (`settings.experimentalAgentDashboard === true`), the per-workspace agent activity they see on each worktree card should survive an Orca app restart.
Per-workspace agent activity shown on each worktree card should survive an Orca app restart.
The user-visible problem is broader than "done rows vanish": any agent whose status was visible before quit and which does not re-emit a hook event on its own — `done` agents (process gone, no future event), `blocked` Claudes (idle at a permission prompt, no event until the user acts), and `working` agents that happen to be quiet at quit time — disappears from the dashboard until something happens to make them speak again. The most-cited symptom is `done` rows winking out, but the same restart-erasure hits `blocked` and quiet `working` for exactly the same reason: nothing replays the last-known status across the boundary.
Persisting the hook server's existing per-pane status cache fixes all three at once. The renderer pulls a main-process snapshot after settings and workspace tabs are hydrated, so startup delivery no longer depends on early IPC timing; dismissals still flow back to main through `agentStatus:drop`.
Persisting the hook server's existing per-pane status cache fixes all three at once. The renderer pulls a main-process snapshot after workspace tabs are hydrated, so startup delivery no longer depends on early IPC timing; dismissals still flow back to main through `agentStatus:drop`.
## Scope
@@ -15,14 +15,13 @@ In scope:
- Persist `lastStatusByPaneKey` (the module-level `Map` in `src/main/agent-hooks/server.ts` that already caches the latest hook payload per `paneKey`) to a JSON file under `userData/agent-hooks/`. Hydrate it on `start()` before the HTTP server begins accepting requests.
- Mirror cache mutations to disk through a debounced trailing write so a burst of hook events does not produce N file writes. Force a synchronous final flush on `stop()` so quit-time state is captured.
- Update the on-disk file when `clearPaneState(paneKey)` is called from PTY teardown (`src/main/ipc/pty.ts`), so a pane that closes during a session does not resurrect on the next launch.
- Gate disk _writes_ on `experimentalAgentDashboard === true` so users who have not opted in do not accumulate on-disk hook payloads. Skip hydration when the flag is off at launch.
Out of scope:
- Renderer-side persistence (`retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, `retentionSuppressedPaneKeys`). The renderer's retention slice and `useRetainedAgentsSync` continue to operate purely in memory; their "stay until dismissed" contract is upheld by the live entry being pulled from the main-process status snapshot after renderer hydration.
- Daemon-side persistence. The daemon owns PTY lifetime and scrollback, not hook events. The hook server (in the Electron main process) is the right home.
- Cross-device sync. The on-disk file lives in `userData`; running Orca on a second machine does not carry retained activity.
- Changing the experimental gate itself. The gate stays; this proposal makes the feature behave correctly when it is on.
- Reintroducing the old experimental dashboard gate. Agent activity is now default-on; row visibility is controlled by worktree-card properties.
- A visible "completed N days ago" indicator. The `done`-only retention case where staleness was sharpest in the prior renderer-side draft is unchanged here (`done` rows still auto-evict on dismiss; users who quit mid-task and return next morning see what they saw last).
## Background
@@ -34,9 +33,9 @@ The Electron main process runs a loopback HTTP server (`AgentHookServer` in `src
1. `lastStatusByPaneKey.set(paneKey, payload)` — caches the latest normalized payload per pane (the module-level `Map` in `server.ts`).
2. `this.onAgentStatus?.(payload)` — fires the registered listener.
`src/main/index.ts` registers the listener on window creation. The listener forwards the payload to the renderer via `webContents.send('agentStatus:set', …)`, gated on `experimentalAgentDashboard === true` in `index.ts`. The renderer's `agentStatusByPaneKey` slice receives the IPC and writes the entry. From there, `useRetainedAgentsSync` produces `retainedAgentsByPaneKey` (sticky `done` snapshots), and the rest of the dashboard reads from those two maps.
`src/main/index.ts` registers the listener on window creation. The listener forwards the payload to the renderer via `webContents.send('agentStatus:set', …)`. The renderer's `agentStatusByPaneKey` slice receives the IPC and writes the entry. From there, `useRetainedAgentsSync` produces `retainedAgentsByPaneKey` (sticky `done` snapshots), and the rest of the dashboard reads from those two maps.
The cache exists for a reason that already overlaps with this proposal: hook events arrive while Orca is windowless (common on macOS when the user closes the window but leaves the app running), so re-registering the listener replays everything cached so far. Disk hydration writes into the same map. The renderer now also has a pull-based `agentStatus:getSnapshot` path, which lets startup restore wait until renderer settings and workspace tabs are ready instead of relying on early replay timing.
The cache exists for a reason that already overlaps with this proposal: hook events arrive while Orca is windowless (common on macOS when the user closes the window but leaves the app running), so re-registering the listener replays everything cached so far. Disk hydration writes into the same map. The renderer now also has a pull-based `agentStatus:getSnapshot` path, which lets startup restore wait until workspace tabs are ready instead of relying on early replay timing.
### What survives restart today, indirectly
@@ -106,19 +105,13 @@ Why no field-level cap on payload size: the same payloads are already accepted i
| `lastStatusByPaneKey.delete(...)` in `clearPaneState()` in `server.ts` | PTY for the pane has torn down | Schedule debounced write |
| `lastStatusByPaneKey.clear()` in `stop()` in `server.ts` | Server is shutting down | Schedule write, then synchronously flush before returning |
A single helper inside the class — `private scheduleStatusPersist()` — sets a 250 ms trailing timer, captured per-instance. Each scheduled write reads the current `lastStatusByPaneKey` and the current `experimentalAgentDashboard` setting, then:
- If the gate is on, writes `{ version: 2, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` in `server.ts`).
- If the gate is off, the write is a no-op for users who never opted in (the file does not exist; do not create one). For users who previously opted in, the write deletes the existing file once (one filesystem op, idempotent on subsequent attempts) and stops scheduling further writes until the gate flips back on. Deletion is preferred to "write empty" so a flag-off user never has hook-payload data on disk after the next sync.
A single helper inside the class — `private scheduleStatusPersist()` — sets a 250 ms trailing timer, captured per-instance. Each scheduled write serializes the current `lastStatusByPaneKey` and writes `{ version: 2, entries: {…} }` to `last-status.json` atomically via tmp + rename, matching the `writeEndpointFile()` pattern in `server.ts`.
A `flushStatusPersistSync()` method runs the pending write synchronously. `stop()` calls it before clearing the map so quit-time state is captured even if a debounced write was pending. `will-quit` already has the synchronous order it needs (`agentHookServer.stop()` in `index.ts` runs before `app.quit()` resolves), so the synchronous flush slots in cleanly.
Specifics:
- Gate writes on `experimentalAgentDashboard === true`. Read the setting at write time via the same `store?.getSettings()` accessor `src/main/index.ts` already uses for the IPC forwarding gate. Importing the store handle into the hook server is the cleanest seam; the alternative (passing the gate as a callback into `start()`) works too and keeps the server class agnostic of the store. Pick whichever is closer to the existing dependency direction in the file at implementation time. (Today, the server class has zero direct references to the renderer-side store; pushing a `getSettings`-style callback through `start()` preserves that and is the recommended path.)
- Debounce 250 ms trailing. A burst of hook events from a multi-agent run otherwise produces a write per event. The existing PTY-data path uses similar trailing batching for renderer IPC, so the latency budget is consistent.
- When the gate flips from on → off, delete the existing `last-status.json` once (no-op if it does not exist) and skip subsequent writes until the flag flips back on. Use deletion rather than "write empty" so a flag-off user has no hook-payload data on disk afterwards. The in-memory cache stays populated — the gate only controls IPC forwarding to the renderer (in `index.ts`) and disk persistence. Cursor-agent's title-driving in `index.ts` operates on the live event payload (not on `lastStatusByPaneKey.get(...)`), so leaving the cache populated is not strictly required for cursor; we leave it because (a) the cache is already populated by every hook event regardless of gate state and pruning it adds complexity for no gain, and (b) flipping the gate back on later should resume from the same state without re-collecting events.
- When the gate flips from off → on, do not eagerly write. The next `set`/`delete` will pick up persistence naturally on its scheduled trigger, and any payload already in the in-memory cache will be persisted on the first write after the flip.
- Identity check before write: if the JSON-stringified payload is byte-identical to the last persisted payload (cached on the instance as `lastWrittenJson`), skip the write. Cheap protection against re-firing trailing timers when nothing actually changed.
`clearPaneState(paneKey)` (called from `src/main/ipc/pty.ts` on PTY teardown) already handles the within-session "pane is gone" case by deleting from the in-memory map. Mirroring it to disk through the same scheduled write means a pane closed in the previous session is absent from the on-disk file the next time it's read, so hydration cannot resurrect it.
@@ -133,7 +126,7 @@ This means:
- IPC channel: a new `agentStatus:drop` request (renderer → main). Single argument: `paneKey: string`. Fire-and-forget; no response needed.
- Renderer wiring: extend the `dropAgentStatus` action in `agent-status.ts` to invoke the IPC at the end of its zustand `set(...)` block. The existing intra-renderer behavior is unchanged; the IPC is a write-through mirror, exactly like the disk persistence side of the hook server.
- Main wiring: add a tiny IPC handler in `src/main/index.ts` (or a dedicated handler file) that calls `agentHookServer.dropStatusEntry(paneKey)`. Keep it gated on the same `experimentalAgentDashboard === true` check used elsewhere in main — even though `dropStatusEntry` is itself idempotent, the gate prevents a non-opted-in user's renderer (which still calls `dropAgentStatus` for non-dashboard reasons like cursor-agent OSC fallback) from churning the hook server's persistence path.
- Main wiring: add a tiny IPC handler in `src/main/index.ts` (or a dedicated handler file) that calls `agentHookServer.dropStatusEntry(paneKey)`. `dropStatusEntry` is idempotent, so repeated renderer sends are harmless.
- `dismissRetainedAgent` does not need its own IPC — `dropAgentStatus` covers the "remove this paneKey from everything" intent, and the dismiss-from-retained-only case is rare enough (and benign enough) to ride on the same path. If we later separate the two, add `agentStatus:drop` symmetry then.
- `dismissRetainedAgentsByWorktree` (the worktree-scoped bulk dismissal action in `agent-status.ts`) DOES need to propagate. Currently no production code path calls it — the live worktree-archive flow goes through `dropAgentStatusByTabPrefix` per-tab and PTY teardown then propagates `clearPaneState` (see below). We add the fan-out defensively so a future caller cannot reintroduce a disk leak. Wiring: in the slice action's set callback, collect the paneKeys being removed, then after the set completes, fire one `window.api.agentStatus.drop(paneKey)` per key. The IPC surface stays at a single channel; only the call count fans out. We do NOT add a separate `agentStatus:dropByWorktree` IPC because per-paneKey overhead is not a concern at the call rates this action would be invoked, and a single channel keeps the main-process handler shape simple.
- `dropAgentStatusByTabPrefix` (in `agent-status.ts`, called from `terminals.ts` on tab close and on worktree shutdown/sleep) does NOT need IPC fan-out. Every caller subsequently kills the affected PTYs via `window.api.pty.kill(...)`, and the existing main-process PTY-onExit chain calls `clearProviderPtyState` → `agentHookServer.clearPaneState(paneKey)` (in `src/main/ipc/pty.ts`), which evicts the persisted entry through the same scheduled-write helper that handles every other eviction. The disk follows naturally.
@@ -142,7 +135,7 @@ The `agentStatus:drop` and `agentStatus:getSnapshot` channels are the IPC surfac
## Hydration Rules
Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (in `server.ts`) — so the in-memory map is fully populated before the first hook POST can arrive. The renderer reads that hydrated map through `agentStatus:getSnapshot` after settings and workspace tabs are ready.
Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (in `server.ts`) — so the in-memory map is fully populated before the first hook POST can arrive. The renderer reads that hydrated map through `agentStatus:getSnapshot` after workspace tabs are ready.
Order of operations on launch:
@@ -150,9 +143,7 @@ Order of operations on launch:
2. Inside `start()`, before binding the HTTP server: read `userData/agent-hooks/last-status.json`, parse, sanitize, and `lastStatusByPaneKey.set(...)` each surviving entry. Failure modes (missing file, parse error, schema mismatch) all degrade gracefully — log once, leave the in-memory map empty.
3. The HTTP server binds and starts accepting POSTs. New hook events go through the same `lastStatusByPaneKey.set` write site (in the HTTP handler in `server.ts`), overwriting hydrated entries naturally.
4. Window creation registers the renderer listener via `setListener()` in `index.ts`. Live pushes remain best-effort, but the startup restore no longer depends on these early sends being accepted by the renderer.
5. `useIpcEvents.ts` waits until `settings.experimentalAgentDashboard === true` and `workspaceSessionReady === true`, then invokes `window.api.agentStatus.getSnapshot()` and applies each surviving entry through `setAgentStatus(...)`.
Gate hydration on `experimentalAgentDashboard === true` at launch. When the flag is off, skip reading the file (the IPC forwarding gate in `index.ts` already drops anything that arrives anyway, and we want to avoid loading event payloads into memory the user has not opted into). The next persistence pass deletes any existing file so a flag-off user does not keep hook payloads on disk.
5. `useIpcEvents.ts` waits until `workspaceSessionReady === true`, then invokes `window.api.agentStatus.getSnapshot()` and applies each surviving entry through `setAgentStatus(...)`.
Sanitization is field-by-field rather than whole-entry rejection so a single bad pane cannot tank the whole hydration:
@@ -173,11 +164,11 @@ Hydration timing relative to window creation:
- The hook server starts before the window (both calls live in `src/main/index.ts`). Hydration is therefore complete before any renderer code can register a listener.
- `setListener()` is registered synchronously inside `openMainWindow()` (in `src/main/index.ts`) and may replay before the renderer bundle has loaded or before `tabsByWorktree` is hydrated.
- The renderer treats those early pushes as disposable: if settings or workspace state is not ready, `useIpcEvents.ts` ignores the push. Once both are ready, it pulls the authoritative snapshot from main and applies entries whose paneKeys still resolve to live tabs.
- The renderer treats those early pushes as disposable: if workspace state is not ready, `useIpcEvents.ts` ignores the push. Once it is ready, it pulls the authoritative snapshot from main and applies entries whose paneKeys still resolve to live tabs.
To make hydration visible after launch, we add **one small renderer change** plus a deferred snapshot trigger:
1. **Renderer snapshot pull.** Modify `useIpcEvents.ts` so live `agentStatus:set` pushes are applied only when the dashboard is enabled and `workspaceSessionReady` is true. Register a store subscription in the same effect; once those conditions are true, call `window.api.agentStatus.getSnapshot()`, re-resolve each paneKey, drop entries whose tab is still unknown, and call `setAgentStatus(...)` for the rest.
1. **Renderer snapshot pull.** Modify `useIpcEvents.ts` so live `agentStatus:set` pushes are applied only when `workspaceSessionReady` is true. Register a store subscription in the same effect; once that condition is true, call `window.api.agentStatus.getSnapshot()`, re-resolve each paneKey, drop entries whose tab is still unknown, and call `setAgentStatus(...)` for the rest.
2. **Snapshot timing.** Include `receivedAt` and `stateStartedAt` in both the persisted file and the IPC payload. `setAgentStatus` accepts those timestamps so restored rows retain their actual age, and it ignores an older snapshot entry if a newer live push already landed for the same pane.
@@ -208,27 +199,22 @@ The renderer slice's persistence model is otherwise unchanged, and `useRetainedA
**SSH worktrees.** Hook payloads for panes whose worktree happens to be SSH-mounted are persisted exactly like local panes — the hook server only sees the paneKey and the payload, not the worktree's transport. If the SSH connection drops post-restart, the hydrated entry remains visible until the renderer's existing tab/worktree filters take it out. Same behavior as in-session today.
**Settings flag flipped off after a session populated the file, then quit.** The flag flip itself triggered a one-shot delete of `last-status.json` (see Write Rules). The next launch with the gate off skips hydration regardless. Flipping the flag back on later starts from no on-disk state; the dashboard is empty until the next hook event repopulates the cache and the next scheduled write rewrites the file.
**Settings flag flipped off mid-session.** The next debounced write deletes `last-status.json` (idempotent) and subsequent writes are skipped while the flag stays off. The in-memory cache is _not_ cleared so a later flip back on resumes from the same state.
## Implementation Notes
Primary file:
- `src/main/agent-hooks/server.ts` — all changes land here:
- Add a private `lastStatusFilePath: string | null` set in `start()` from `userDataPath`.
- Add a private `getDashboardEnabled: (() => boolean) | null` callback set from `start({ getDashboardEnabled })`. The hook server stays decoupled from the store — `index.ts` passes a closure that reads `store?.getSettings().experimentalAgentDashboard === true`.
- Add `private hydrateLastStatusFromDisk()` invoked at the top of `start()` (gated on `getDashboardEnabled?.() === true`). Reads file, `JSON.parse`s the envelope, sanitizes field-by-field, validates each `entry.payload` via `normalizeAgentStatusPayload` (in `src/shared/agent-status-types.ts` — accepts `unknown`, returns parsed `ParsedAgentStatusPayload | null`), and calls `lastStatusByPaneKey.set(...)` for survivors. Use `normalizeAgentStatusPayload`, not `parseAgentStatusPayload` — the latter takes a JSON string only and would force a wasteful stringify/parse round-trip.
- Add `private hydrateLastStatusFromDisk()` invoked at the top of `start()`. Reads file, `JSON.parse`s the envelope, sanitizes field-by-field, validates each `entry.payload` via `normalizeAgentStatusPayload` (in `src/shared/agent-status-types.ts` — accepts `unknown`, returns parsed `ParsedAgentStatusPayload | null`), and calls `lastStatusByPaneKey.set(...)` for survivors. Use `normalizeAgentStatusPayload`, not `parseAgentStatusPayload` — the latter takes a JSON string only and would force a wasteful stringify/parse round-trip.
- Add `private scheduleStatusPersist()` and `private flushStatusPersistSync()`. Wire them into the three mutation sites: HTTP handler `set`, `clearPaneState` `delete`, and `stop()` `clear` (all in `server.ts`). The `stop()` path uses the synchronous flush before `lastStatusByPaneKey.clear()`.
- Add atomic write helper that mirrors the existing `writeEndpointFile()` pattern (tmp + rename, 0o600 mode, owner-only directory at 0o700 — see the directory and atomic-write helpers in `server.ts`). Tmp filename: `.last-status-<pid>-<uuid>.tmp` (distinct prefix so a future filename change to one writer does not affect the other). Extend the existing orphan-tmp sweep in `server.ts` to match either prefix (`.endpoint-` OR `.last-status-`) so a crash mid-write of either file is cleaned up by either subsequent start.
- Add `getStatusSnapshot()` so the renderer can pull the current hydrated map after its own settings/session state is ready. `setListener()` remains unchanged for the existing windowless-window recreation path.
- Add `getStatusSnapshot()` so the renderer can pull the current hydrated map after its own workspace session state is ready. `setListener()` remains unchanged for the existing windowless-window recreation path.
- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})`. Forward live `agentStatus:set` payloads with `receivedAt` and `stateStartedAt`. Existing `setListener` registration remains unchanged.
- `src/main/index.ts` — forward live `agentStatus:set` payloads with `receivedAt` and `stateStartedAt`. Existing `setListener` registration remains unchanged.
- `src/main/ipc/agent-hooks.ts` — add `agentStatus:getSnapshot`, gated on `experimentalAgentDashboard === true`, returning `agentHookServer.getStatusSnapshot()`. The existing `agentStatus:drop` handler continues to call `agentHookServer.dropStatusEntry(paneKey)`. `clearPaneState` is reserved for PTY-teardown, where wiping prompt/tool caches alongside the status entry is correct.
- `src/main/ipc/agent-hooks.ts` — add `agentStatus:getSnapshot`, returning `agentHookServer.getStatusSnapshot()`. The existing `agentStatus:drop` handler continues to call `agentHookServer.dropStatusEntry(paneKey)`. `clearPaneState` is reserved for PTY-teardown, where wiping prompt/tool caches alongside the status entry is correct.
- `src/renderer/src/hooks/useIpcEvents.ts` — make the `agentStatus.onSet` handler require both the dashboard gate and `workspaceSessionReady`. Add a store subscription that calls `window.api.agentStatus.getSnapshot()` once when those conditions become true, re-resolves each paneKey, drops unknown-tab entries, and applies the rest through `setAgentStatus` with timing metadata.
- `src/renderer/src/hooks/useIpcEvents.ts` — make the `agentStatus.onSet` handler require `workspaceSessionReady`. Add a store subscription that calls `window.api.agentStatus.getSnapshot()` once when the workspace session becomes ready, re-resolves each paneKey, drops unknown-tab entries, and applies the rest through `setAgentStatus` with timing metadata.
- `src/renderer/src/store/slices/agent-status.ts` — extend `setAgentStatus` to accept optional `{ updatedAt, stateStartedAt }` timing. Use those values for snapshot hydration, and ignore stale snapshot entries if a newer live push is already present.
@@ -242,16 +228,15 @@ Tests:
- `src/main/agent-hooks/server.test.ts` — extend with:
- Sanitization unit tests: bad version, missing entries, malformed paneKey, key/embedded paneKey mismatch, payload that fails `normalizeAgentStatusPayload`.
- Persistence unit tests: a `set` schedules a write; the debounce coalesces two rapid `set`s into one write; identical-payload writes are skipped; `stop()` flushes synchronously; flipping the gate from on → off deletes the file (or no-ops if absent) and skips subsequent writes.
- Persistence unit tests: a `set` schedules a write; the debounce coalesces two rapid `set`s into one write; identical-payload writes are skipped; `stop()` flushes synchronously.
- Hydration unit tests: starting with a populated file populates `lastStatusByPaneKey` before the HTTP listener binds; missing file is a no-op; corrupt file is a no-op + warn.
- Snapshot test: after hydrate, `getStatusSnapshot()` returns the hydrated entries with timing metadata.
- Gate test: with `getDashboardEnabled` returning false, `start()` skips hydration and `set` skips writes.
- Existing `useRetainedAgents.test.ts` runs unchanged.
- New renderer test in the existing agent-status slice test file: `dropAgentStatus` fires `window.api.agentStatus.drop(paneKey)` exactly once after its zustand mutation; double-drop on the same paneKey only fires once (idempotent).
- New IPC handler tests in the existing main-process test suite: `agentStatus:drop` with the gate on calls `dropStatusEntry`; with the gate off, the call is a no-op. `agentStatus:getSnapshot` returns cached entries with the gate on and an empty array with the gate off.
- New snapshot tests in `useIpcEvents.test.ts`: early `agentStatus:set` pushes before settings/session readiness are ignored, `getSnapshot()` is pulled once after readiness, entries whose tabs are still unknown are discarded, and no snapshot is requested while the dashboard gate is off.
- New IPC handler tests in the existing main-process test suite: `agentStatus:drop` calls `dropStatusEntry`; `agentStatus:getSnapshot` returns cached entries.
- New snapshot tests in `useIpcEvents.test.ts`: early `agentStatus:set` pushes before session readiness are ignored, `getSnapshot()` is pulled once after readiness, and entries whose tabs are still unknown are discarded.
- New worktree-archive IPC fan-out test on the slice: calling `dismissRetainedAgentsByWorktree(worktreeId)` fires `window.api.agentStatus.drop(paneKey)` once for each retained paneKey under that worktree.
- Integration sanity check (manual or in the dashboard suite): a `done` agent finishes → quit → file is written → relaunch → renderer's `agentStatusByPaneKey` is populated after settings/session hydration and snapshot pull → the inline list renders the row → dismiss → re-quit → relaunch → no row.
- Integration sanity check (manual or in the dashboard suite): a `done` agent finishes → quit → file is written → relaunch → renderer's `agentStatusByPaneKey` is populated after workspace session hydration and snapshot pull → the inline list renders the row → dismiss → re-quit → relaunch → no row.
Telemetry:
@@ -271,7 +256,7 @@ Follow-ups (explicitly out of this design):
- Persisting `retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, or `retentionSuppressedPaneKeys`. The renderer's retention slice is computed downstream from the live cache; the live cache is now persistent, so retention falls out for free.
- Daemon-side persistence. The daemon owns PTY lifetime and does not see hook events. The hook server (in the Electron main process) is the right home.
- Cross-device sync. The persisted file lives in local `userData/agent-hooks/`; running Orca on a second machine does not carry status over.
- A dedicated settings UI. The behavior is implicit when the experimental dashboard is on; no new toggle is added.
- A dedicated settings UI. Agent activity is default-on; no new toggle is added.
- Backfill or migration for users on prior builds. Absence of the file is the same as an empty cache.
## Why this beats the renderer-side alternative
@@ -286,7 +271,7 @@ The renderer-side approach was strictly smaller code; the upstream approach is s
## Acceptance Criteria
- **Done row preservation.** Toggle the experimental agent dashboard on. Run an agent to completion. Quit and relaunch Orca. The completion row appears in the worktree's inline agents list after renderer settings and workspace tabs hydrate and the snapshot pull completes.
- **Done row preservation.** Run an agent to completion. Quit and relaunch Orca. The completion row appears in the worktree's inline agents list after workspace tabs hydrate and the snapshot pull completes.
- **Blocked row preservation.** Same flow, but quit while a Claude is sitting at a permission prompt (`blocked`). After relaunch, the blocked row is visible with the same prompt text and tool name.
- **Quiet working preservation.** Same flow, but quit while an agent is in `working` state and not currently emitting events. After relaunch, the row shows `working` with the last-known tool/prompt context. As soon as the agent emits its next event, the row updates naturally.
- **Live overwrite.** A hydrated row is overwritten when a new hook event fires for the same paneKey post-restart. No UI artifacts (no flicker, no double-rendered row, no orphan retention).
@@ -295,8 +280,5 @@ The renderer-side approach was strictly smaller code; the upstream approach is s
- **Worktree archive is sticky across restart.** Archive a worktree that has retained agents. Quit and relaunch. None of those rows reappear and `last-status.json` no longer contains them (verified by inspecting the file on disk).
- **Worktree archived between sessions.** A row is hydrated for a paneKey whose worktree was archived during the previous session. The row does not appear in the dashboard (the renderer's per-worktree filter hides it). No errors thrown.
- **Tab closed between sessions (race window).** A row is hydrated for a paneKey whose tab was closed in the gap between final write and quit. The row does not appear in the dashboard (the renderer's per-tab filter hides it).
- **Flag off the entire time.** With `experimentalAgentDashboard` off, the file is never written. `userData/agent-hooks/last-status.json` does not appear on disk. The hook server still runs (cursor-agent title-driving in `index.ts` still works).
- **Flag flipped off mid-session.** A populated cache exists in memory; the user toggles the flag off. The next scheduled write deletes `last-status.json` (idempotent if already absent) and subsequent scheduled writes are skipped. Memory state is unchanged.
- **Flag flipped off, then quit, then on.** With the flag off, the file was deleted by the gate-flip-off path (or never created if the user never opted in). Quit. Relaunch with the flag back on; the file is absent → hydration is a no-op → the dashboard starts clean. The first new hook event after launch will recreate the file from a fresh write.
- **Corrupt file.** Hand-edit `last-status.json` to invalid JSON. Relaunch. Single console warn, dashboard renders normally, no rows from the corrupt file. The first new event repopulates the file from a clean state.
- **Bounded renderer changes.** `src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, and `src/renderer/src/App.tsx` are not modified. Existing tests in `useRetainedAgents.test.ts` pass unchanged. The renderer change is limited to snapshot application in `useIpcEvents.ts` and timing-aware `setAgentStatus(...)` hydration.
+118
View File
@@ -0,0 +1,118 @@
# GitHub Work Item Drawer: Cache & Latency
## Problem
`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again.
The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota.
## What is true today
- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization.
- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway.
- The issue details path is currently multi-call:
1. `getWorkItem` (`/issues/:n` or PR fallback).
2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`).
3. `getWorkItemParticipants` (GraphQL participants).
4. `getMentionParticipants` (GraphQL user hydration for visible authors).
So “3 spawns” understates the real hot path.
## Goals
- Reopen latency should be near-instant for recently viewed items.
- Cold issue-open latency should drop by reducing `gh` command count.
- Correctness must survive local mutations, repo/source switches, and multi-window usage.
## Non-goals
- No disk-persistent cache in this iteration.
- No attempt to replace PR files/diff path with GraphQL.
## Design
### 1. Renderer SWR cache with paint-first reads
Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by:
`repoPath + issueSourcePreference + type + number`
Cache value:
- `details`
- `fetchedAt`
- `pending?: Promise`
- `error?: string`
Behavior:
1. On open, render cached `details` immediately when present (do not clear to `null`).
2. If entry age <= `FRESH_MS` (30s), skip fetch.
3. If stale or missing, fetch in background and replace cache + UI when resolved.
4. On fetch failure with cached data, keep stale data visible and show non-blocking error state.
5. On fetch failure without cached data, show blocking error state.
### 2. In-flight dedupe in renderer
Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise.
This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions.
### 3. Explicit invalidation rules
“Background refetch is authoritative” is necessary but not sufficient.
Invalidate (or patch + mark stale) on successful local mutations:
- issue state/labels/assignees/body edits
- new comments/reactions
- PR review comment create/resolve
Scope:
- per-item key in current window
- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too
Also invalidate on context switches:
- `repoPath` change
- issue source preference change (`origin`/`upstream`/`auto`)
- sign-out/account change
For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header.
### 4. Main-process issue fetch collapse (GraphQL-first)
Do not claim “one call returns everything” unless we actually ship and verify it.
Feasible single GraphQL issue query fields:
- issue body
- labels
- assignees
- participants
- comments(first: N) with author login + avatarUrl + body + createdAt + url
Limits and required fallbacks:
- GraphQL pagination still applies (`first: 100`). More comments require paging.
- Some comment authors can be null/ghost; renderer must keep existing fallback behavior.
- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path.
- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it.
PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope.
## Edge cases this design must handle
- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it.
- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry.
- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry.
- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays).
- Unauthorized/404 should not overwrite valid cached data with empty shells.
## Rollout
1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior.
2. Add mutation-driven invalidation and cross-window invalidation event.
3. Implement GraphQL-first issue details with strict fallback.
4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after.
+5 -5
View File
@@ -105,14 +105,14 @@ Invariants:
| Current driver | Trigger | Next driver | Side effect |
|---|---|---|---|
| `idle` | mobile subscribes with `displayMode='auto'\|'phone'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims |
| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — |
| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims |
| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — |
| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") |
| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto/phone |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto |
| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) |
| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) |
| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims |
@@ -174,7 +174,7 @@ connected; mobile sees the desktop-sized terminal because PTY snapped back
**Mobile types something while you're reclaimed.** Banner reappears. Your
next keystroke is blocked. PTY may snap back to phone dims (if mobile is in
auto/phone mode).
auto mode).
**Mobile disconnects.** Banner gone permanently. Driver returns to `idle`.
@@ -207,7 +207,7 @@ This is asymmetric and we are accepting it deliberately:
- **Mobile reclaim is naturally signaled.** When a mobile user types
while desktop drives, the runtime flips the driver to
`mobile{thatClient}`, the desktop banner remounts, and (if mobile is in
auto/phone mode) the PTY snaps back to phone dims. The mobile user sees
auto mode) the PTY snaps back to phone dims. The mobile user sees
the pane reflow and their keystrokes appear in the output stream. There
is no silent black-hole condition on mobile that a banner would
resolve.
@@ -255,7 +255,7 @@ listener. The `TerminalPane` banner mounts when
### Why not reuse `getFitOverrideForPty`?
The fit override only fires when the PTY was actually resized
(`mode='auto'|'phone'` and `wasResizedToPhone=true`). It misses the
(`mode='auto'` and `wasResizedToPhone=true`). It misses the
desktop-mode case where mobile is subscribed but no resize happened. The
driver state is broader than the fit override.
+406
View File
@@ -0,0 +1,406 @@
# Single Shared RPC Client per Host (Mobile)
Design doc for collapsing the per-screen WebSocket connection model into a
single shared `RpcClient` per paired host, owned by a React context that
sits above the route tree.
## Problem
The mobile app today opens **one WebSocket per screen per host**:
| Screen | Connections per host |
|---|---|
| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) |
| Host detail (`app/h/[hostId]/index.tsx`) | +1 |
| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 |
| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 |
| Pair confirm (briefly) | +1 |
A user actively browsing one host typically holds **3–4 simultaneous
sockets** to the desktop runtime. Each call to `connect()` runs its own
E2EE handshake, allocates an ephemeral keypair, and runs an independent
reconnect loop with exponential backoff.
This causes three observable problems:
1. **Stuck-connecting / reconnecting for minutes.** The desktop's
`MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who
navigates rapidly accumulates stale sockets faster than they can be
reaped by TCP keepalive (which can take 60–300s on default systems
for half-open connections from a phone leaving Wi-Fi range or
backgrounding). Once the cap is hit, new sockets are rejected with WS
close code `1013 Maximum connections reached`. The mobile reconnect
loop does not recognize 1013 as terminal — it retries with backoff,
each retry also dropped, until enough stale sockets are reaped.
Result: a screen that should connect in <1s is stuck for 1–5 minutes.
2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')`
awaits `waitForConnected()`. If the session-screen client is in
`connecting`/`reconnecting` state because its socket lost the cap
race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires.
The user sees nothing happen.
3. **Triple cost on every cold-start.** Three E2EE handshakes,
three Curve25519 keypair generations, three subscription
re-registrations on every app launch. On low-end Android, this
visibly delays first paint by hundreds of milliseconds.
The architecture also wastes server resources: each socket carries its
own E2EE channel, its own subscription set, its own driver-state-machine
client identity (cf. `docs/mobile-presence-lock.md`). The server already
has logic to reconcile multi-socket-per-token tear-down
(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because*
this design forced the question; with a single client per host, it becomes
unnecessary.
## Today's transport ownership
Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`:
```
mobile/app/pair-confirm.tsx # one-shot during pairing
mobile/app/pair-scan.tsx # one-shot during pairing
mobile/app/index.tsx # N (one per paired host)
mobile/app/h/[hostId]/index.tsx # 1 (host detail)
mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session)
mobile/app/h/[hostId]/accounts.tsx # 1 (accounts)
```
Each owns a `useRef<RpcClient | null>` and calls `client.close()` from a
cleanup function. The home screen additionally maintains a
`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host
client survives across navigation events.
This pattern works for *correctness* — every cleanup eventually closes
its socket — but it breaks under three real-world conditions:
1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup
`client.close()` runs after a new screen has already opened a fresh
socket to the same host. Two sockets briefly coexist for the same
token, multiplied across screens.
2. **Network drops.** A backgrounded/locked phone on a flaky network
leaves sockets half-open. The server doesn't get a FIN; cleanup
relies on TCP keepalive timing. Meanwhile the foreground app, on
resume, opens fresh sockets. The half-open ones eat the cap until
reaped.
3. **Hot reload during dev.** Each Metro hot reload fires a new render
tree without unmounting the old, so connections leak.
## Goal
> A paired host has at most **one active WebSocket** at any time, owned
> by a context provider above the route tree. All screens for that host
> share that client. The pair flows are the only places that create
> short-lived clients (and they explicitly close those after pairing
> completes).
This is the architectural fix to the symptoms above. Combined with the
two recently-shipped hotfixes (token cache + stable `useEffect`
dependency on home screen), this completes the connection-lifecycle
work.
## Design
### Layered ownership
```
RootLayout (<RpcClientProvider>)
└── routes
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
├── h/[hostId]/ // host detail
├── h/[hostId]/session/[worktreeId]/ // session
└── h/[hostId]/accounts/ // accounts
```
Two providers, layered:
1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host,
keyed by `hostId`. Lifecycle: opens on first request for that
host's client, holds open until app shutdown OR until the host is
removed (`removeHost(hostId)` triggers explicit close). Reuses
existing `loadHosts()` cache from the recently-merged
`host-store.ts` work.
2. **`HostScopedClientGate` (per host)** — a thin route-layout
component placed at `app/h/_layout.tsx`. Reads `hostId` from
route params, requests the client for that host from the root
provider, exposes it via context to descendants, and renders a
loading state until the client reaches `connected`. Guarantees
every descendant screen sees the same client instance for that
host — no per-screen `connect()` calls.
The home screen (`app/index.tsx`) lives outside `HostScopedClientGate`
since it spans all hosts; it consumes the root provider directly via
a multi-host hook (see API below).
### API
```ts
// New file: mobile/src/transport/client-context.tsx
type RpcClientContext = {
// Get-or-open. Returns the singleton client for hostId; opens it
// lazily on first call, reuses it for all subsequent callers. Never
// returns null (returns a placeholder client in 'connecting' state
// if open hasn't completed).
getClient: (hostId: string) => RpcClient
// Connection state for a given host (driven by client.onStateChange).
useHostState: (hostId: string) => ConnectionState
// Useful for the home screen which renders all hosts at once.
useAllClients: () => Array<{ hostId: string; client: RpcClient }>
}
export const RpcClientProvider: React.FC<{ children: React.ReactNode }>
export const useHostClient: (hostId: string) => {
client: RpcClient
state: ConnectionState
}
```
Internal store (single `useRef` in the provider):
```ts
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number // number of active screens holding this client
closeTimer: NodeJS.Timeout | null
}
const store = useRef(new Map<string, StoreEntry>())
```
### Lifecycle rules
1. **Open on first read.** First `getClient(hostId)` call for a host
reads the host record (uses cached `loadHosts()`), then calls
`connect()` and stores the client. Subsequent calls return the
cached entry.
2. **Idle close timer.** When `refCount` drops to 0 (all screens for
that host unmounted), schedule a 30-second close timer. If a screen
for the same host mounts within 30s, cancel the timer. Otherwise,
close the client and remove from the store.
- Why 30s: covers fast tab-switching and back-navigation without
keeping idle sockets forever. Tunable based on observed behavior.
3. **Forced close on host removal.** `removeHost(hostId)` from
`host-store.ts` calls into the provider to close the client
immediately and delete the store entry.
4. **App backgrounded.** No special action — let TCP keepalive and
server-side reaping handle it. Reconnect happens on foreground.
5. **App foregrounded.** Trigger a `getState()` poll on every
non-closed entry; if any are in `disconnected` (TCP died while
backgrounded), the existing reconnect loop handles it. No new
client allocations.
### Public surface for screens
Each screen replaces:
```ts
// Before
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) return
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
setClient(rpcClient)
})()
return () => {
rpcClient?.close()
}
}, [hostId])
```
with:
```ts
// After
const { client, state } = useHostClient(hostId)
```
Total LoC reduction across screens: ~150 lines.
### Pair flow exception
`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()`
directly with **explicit `client.close()`** after the test request returns.
Reason: the host record doesn't yet exist in `loadHosts()` during pairing,
so the provider has nothing to look up. The pair flow's client is a
short-lived transient that delivers `getStatus()` once and then dies.
After `saveHost()` succeeds, the user is navigated away; the next time
they enter `/h/[hostId]/...`, the provider opens a fresh client through
the normal path.
### Streaming subscription handling
The home screen's `accounts.subscribe` stream and the session screen's
terminal subscriptions remain owned by their respective screens — the
provider doesn't manage subscriptions, only the underlying transport.
Each screen's effect calls `client.subscribe(...)` and stores the
returned unsubscribe function. On unmount, the screen unsubscribes
(returns to the existing per-screen pattern, just over a shared
transport). The transport's `subscribe()` already correctly multiplexes
multiple listeners on one WebSocket via the `id` field.
### State propagation
`useHostState(hostId)` returns the live `ConnectionState`. The provider
maintains a per-host `useState` keyed by hostId; the `client.onStateChange`
listener is wired once at client creation and updates the corresponding
state slot. `useHostState` reads from this state via `useSyncExternalStore`
or a context selector — the choice is mostly preference; in this
codebase, given the small state shape, a simple `useContext + useMemo`
of the matching slot is fine.
## Migration
Step-by-step, each step independently shippable:
1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet.
Wire into `app/_layout.tsx`. Existing screens unchanged.
2. **Migrate session screen** (highest-risk, most-used). Replace
per-screen `connect()` with `useHostClient`. Test connection
behavior, terminal create/delete, scrollback hydration.
3. **Migrate host detail and accounts screens.** Same pattern.
4. **Migrate home screen.** Replace `clientsRef` with
`useAllClients()`. The home screen's per-host streaming
subscriptions move into a hook that runs per-host.
5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize
the gate and remove duplicated loading-state logic.
6. **Delete legacy code.** Remove the dead `connect()` import paths
from each screen. Codepoint reduction.
7. **Remove server-side `hasOtherConnections` complexity** in a
follow-up: with one socket per token, the multi-socket reconciliation
in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies.
This is a desktop-side cleanup PR done after mobile rolls out.
Each step is tested in isolation; rollback per step is trivial.
## Risks
### R1: Connection loss while screens are mounted
**Risk.** Today, when a screen unmounts due to network loss, its
client closes and reopens on remount. Under the new design, a
network-loss-during-screen-mounted means the client lives but is in
`reconnecting` state.
**Mitigation.** The existing `RpcClient` already handles this — its
internal reconnect loop runs invisibly. Screens already render based
on `connState === 'connected'`, so they stay in their `connecting`
UI until the loop succeeds. No regression.
### R2: One bad host poisons the singleton
**Risk.** If the client for one host is wedged in `reconnecting` due
to a desktop-side issue, all screens for that host inherit the wedged
state. Under the per-screen design, navigating to a different screen
gave a fresh client with a chance to connect cleanly.
**Mitigation.** "Force reconnect" affordance: a button on the host
detail "Connection issues" UI calls
`provider.forceReconnect(hostId)` — close + reopen the client. Users
who hit a stuck state get a one-tap recovery without uninstalling.
Implemented as part of step 2.
### R3: Idle close timer races
**Risk.** A user navigates from session → home → back to session
within 35 seconds. The 30s idle timer fires between hops and closes
the client; the back-navigation has to wait for a fresh handshake.
**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at
mount time. As long as the consumer holds a reference to the client,
the timer is paused. Standard refcount pattern.
### R4: Memory / state leak on rapid host removal
**Risk.** User removes a host while a screen for it is mounted.
The screen's reference is now dangling.
**Mitigation.** `removeHost(hostId)` triggers an explicit close +
delete from the store. Screens already handle `auth-failed` /
`disconnected` states (the client transitions to one of them on
forced close). Add a navigation-bounce in those states so the screen
returns to the host list.
### R5: Pair-flow socket leaks through provider
**Risk.** If pair-confirm crashes mid-handshake before its explicit
`close()`, the socket leaks.
**Mitigation.** Independent of the provider — same risk exists today.
Add a try/finally + cleanup useEffect in pair-confirm.
### R6: Provider re-initialization on hot reload (dev only)
**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly
spawning new clients while old ones are still in the store.
**Mitigation.** On provider mount, scan the store for entries whose
clients report `closed` state and prune. Acceptable dev-only friction.
## Test Plan
### Unit / hook tests
- `useHostClient` returns the same client instance across multiple
consumers for the same hostId.
- `removeHost(hostId)` immediately closes the client (assert via
`client.getState()`).
- Idle close timer: zero refcount → 30s wait → client closed.
- Idle close timer cancellation: zero refcount → 15s wait →
consumer subscribes → no close.
### Integration / manual
- Create one host; navigate home → host detail → session → back ×10
rapidly. Single socket on desktop (verify via desktop debug log).
- Background app for 5 min; foreground; verify reconnect uses same
client instance, no leak.
- Remove host while session screen mounted; screen bounces back to
home; client closed.
- Force-reconnect button on host detail; client closes and reopens
cleanly.
- Hot reload during development; no socket leak (verify desktop
active-connection count).
### Regression
- Terminal create/delete works during normal browse (Bug A from
initial reports — should never recur once cap pressure is removed).
- Scrollback hydration unchanged.
- Phone-fit / driver-lock state machine unchanged
(`docs/mobile-presence-lock.md` invariants hold).
## Out of scope
- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth
but not needed once mobile self-limits to one socket per host.
- **Application-level ping/pong.** Worth adding but separate concern;
helps server reap dead sockets faster regardless of how many a single
client opens.
- **iOS share extension or system-wide deep-link integration.** Future
product work.
## Effort estimate
3–4 hours including tests and incremental migration. Each step
independently mergeable.
## References
- `docs/mobile-presence-lock.md` — driver-state-machine that depends
on per-client identity. Single-client-per-host simplifies but
doesn't break this contract.
- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration
flow. Subscriptions remain per-screen; transport changes are
transparent.
- `mobile/src/transport/rpc-client.ts` — existing `connect()`
implementation; reconnect loop, E2EE handshake, subscription
multiplexing all preserved as-is.
- `src/main/runtime/rpc/ws-transport.ts` — server-side
`MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation
that becomes simpler post-migration.
+18
View File
@@ -0,0 +1,18 @@
# Sidekick animation state mapping
Orca's normalized agent status model has four hook-reported states: `working`, `blocked`, `waiting`, and `done`. `interrupted` is an optional flag on `done`, not a separate failure state.
Codex pet spritesheets can expose more visual rows than Orca has agent states. Sidekick should treat those row names as an asset contract, not as proof that Codex or Orca reports matching runtime states.
The current mapping is:
| Orca condition | Sidekick animation |
| --- | --- |
| Sidekick is being dragged | `jumping` |
| Any fresh agent is `blocked` or `waiting` | `waiting` |
| Any fresh agent is `working` | `running` |
| Any fresh agent is `done` | `review` |
| Any retained completed agent exists | `review` |
| No fresh or retained agent state exists | `idle` |
`SidekickAnimationName` deliberately omits `failed`. Orca distinguishes interrupted completions from normal completions, but interruption can mean user cancellation rather than agent failure, so mapping it to `failed` would overstate the status until Orca has a real failure/error signal. Codex spritesheets may still expose a `failed` row — that row stays as part of the asset contract but is never selected at runtime.
+31
View File
@@ -138,6 +138,37 @@ cd ..
pnpm typecheck:node
```
## Protocol Version Compatibility
Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on `status.get` so a genuinely incompatible combo can hard-block instead of silently misbehaving.
Constants live in two files (Metro can't resolve outside `mobile/`):
- `src/shared/protocol-version.ts` — `DESKTOP_PROTOCOL_VERSION`, `MIN_COMPATIBLE_MOBILE_VERSION`
- `mobile/src/transport/protocol-version.ts` — `MOBILE_PROTOCOL_VERSION`, `MIN_COMPATIBLE_DESKTOP_VERSION`
Today all four are set so `evaluateCompat` always returns `{ kind: 'ok' }` — nothing blocks. The wire format is in place to flip a switch when needed.
### When to bump
Bump `DESKTOP_PROTOCOL_VERSION` (and the mobile mirror `MOBILE_PROTOCOL_VERSION` when relevant) for **breaking** changes:
- Removed RPC method or required parameter that mobile uses
- Changed meaning (units, nullability) of an existing field mobile reads
- Changed encryption, framing, or auth handshake
Do **not** bump for additive changes:
- New RPC methods
- New optional fields on existing methods
- New event types in `terminal.subscribe`
Set `MIN_COMPATIBLE_MOBILE_VERSION` (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for `MIN_COMPATIBLE_DESKTOP_VERSION` from the mobile side.
When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` renders a screen pointing the user at either the App Store (mobile too old) or GitHub Releases (desktop too old).
To exercise the block screen locally: set `MIN_COMPATIBLE_DESKTOP_VERSION = 999` in `mobile/src/transport/protocol-version.ts`, rebuild, pair to any desktop. Revert before merging.
## Mock Server
Develop the mobile app without a running Orca desktop instance:
+29 -6
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.2",
"version": "0.0.6",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -14,14 +14,34 @@
"backgroundColor": "#111111"
},
"ios": {
"supportsTablet": true,
"supportsTablet": false,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "3",
"buildNumber": "1",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true
}
},
"ITSAppUsesNonExemptEncryption": false
},
"privacyManifests": {
"NSPrivacyTracking": false,
"NSPrivacyTrackingDomains": [],
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
}
]
}
},
"android": {
@@ -31,7 +51,8 @@
},
"usesCleartextTraffic": true,
"allowBackup": false,
"package": "com.stably.orca.mobile"
"package": "com.stably.orca.mobile",
"versionCode": 1
},
"plugins": [
"expo-router",
@@ -45,7 +66,9 @@
[
"expo-camera",
{
"cameraPermission": "Allow Orca to use the camera for QR code scanning."
"cameraPermission": "Allow Orca to use the camera for QR code scanning.",
"microphonePermission": false,
"recordAudioAndroid": false
}
],
[
+75 -27
View File
@@ -1,11 +1,13 @@
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack } from 'expo-router'
import { Stack, useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -26,7 +28,49 @@ Notifications.setNotificationHandler({
})
})
// Why: extract the path+payload that follows the orca://pair anchor so we
// can route it to the confirm screen. Accept either a hash payload
// (`orca://pair#<base64>`, the QR / shared form) or a query param
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
// fragments).
function extractPairCode(url: string): string | null {
if (!url.startsWith('orca://pair')) return null
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
return url.slice(hashIndex + 1) || null
}
const queryIndex = url.indexOf('?')
if (queryIndex !== -1) {
const params = new URLSearchParams(url.slice(queryIndex + 1))
return params.get('code')
}
return null
}
export default function RootLayout() {
const router = useRouter()
// Why: route `orca://pair#<code>` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
// covers cold-start (link tapped while app was closed); the listener
// covers warm-start (link tapped while app is in memory).
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairCode(url)
if (code) {
router.push({ pathname: '/pair-confirm', params: { code } })
}
}
void Linking.getInitialURL().then((url) => {
if (url) handleUrl(url)
})
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
return () => sub.remove()
}, [router])
// Why: hide the native splash only once the navigation Stack has been laid
// out — this is the earliest moment the user will see actual app content.
// Previously the splash hid when a placeholder View rendered, leaving a
@@ -36,32 +80,36 @@ export default function RootLayout() {
}, [])
return (
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</View>
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</View>
</RpcClientProvider>
)
}
+23 -1
View File
@@ -1,11 +1,25 @@
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, Globe } from 'lucide-react-native'
import Svg, { Path } from 'react-native-svg'
import Constants from 'expo-constants'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
// Why: read version + native build identifier from expo-constants at
// runtime so the About screen never drifts out of sync with app.json.
// nativeBuildVersion is iOS buildNumber on iOS and versionCode on
// Android — different concepts, same role (monotonic native build id).
function getVersionLabel(): string {
const version = Constants.expoConfig?.version ?? '?.?.?'
const build =
Platform.OS === 'ios'
? Constants.expoConfig?.ios?.buildNumber
: String(Constants.expoConfig?.android?.versionCode ?? '')
return build ? `v${version} (${build})` : `v${version}`
}
function GithubIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
@@ -66,6 +80,8 @@ export default function AboutScreen() {
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
<Text style={styles.versionText}>{getVersionLabel()}</Text>
</View>
)
}
@@ -141,5 +157,11 @@ const styles = StyleSheet.create({
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
versionText: {
marginTop: spacing.lg,
textAlign: 'center',
fontSize: typography.metaSize,
color: colors.textMuted
}
})
+414
View File
@@ -0,0 +1,414 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
RefreshControl,
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
export default function AccountsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { hostId } = useLocalSearchParams<{ hostId: string }>()
// Why: shared client per host. See docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const [hostName, setHostName] = useState<string>('')
const [snapshot, setSnapshot] = useState<AccountsSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
useEffect(() => {
clientRef.current = client
}, [client])
useEffect(() => {
if (!hostId) return
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
setHostName(host.name)
})
return () => {
stale = true
}
}, [hostId])
// Why: subscribe to streaming snapshot updates so usage bars refresh in
// place when the desktop's rate-limit poll completes (every 5 min) or
// when the user switches accounts. Falls back to a one-shot accounts.list
// if the subscription stream errors.
useEffect(() => {
if (!client || connState !== 'connected') return
const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') return
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
setSnapshot(evt.snapshot)
setError(null)
}
})
return unsubscribe
}, [client, connState])
const refresh = useCallback(async () => {
if (!client) return
setRefreshing(true)
try {
const res = await client.sendRequest('accounts.list')
if (res.ok) {
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
setError(null)
} else {
setError(res.error.message)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setRefreshing(false)
}
}, [client])
const selectAccount = useCallback(
async (provider: ProviderKey, accountId: string | null) => {
if (!client) return
setBusyAccountId(accountId ?? `${provider}:default`)
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
try {
const res = await client.sendRequest(method, { accountId })
if (!res.ok) {
Alert.alert('Could not switch account', res.error.message)
} else {
// Why: optimistic refresh — the streaming subscription will also
// emit, but a one-shot keeps the UI responsive even if the stream
// is temporarily disconnected.
await refresh()
}
} catch (e) {
Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e))
} finally {
setBusyAccountId(null)
}
},
[client, refresh]
)
const renderProviderSection = (provider: ProviderKey, title: string) => {
if (!snapshot) return null
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Icon size={14} />
<Text style={styles.sectionHeading}>{title}</Text>
</View>
<View style={styles.card}>
{/* System default row */}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, null)}
disabled={busyAccountId !== null || connState !== 'connected'}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === `${provider}:default` ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</Pressable>
{state.accounts.map((account) => {
const isActive = state.activeAccountId === account.id
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
const session = usage?.session
const weekly = usage?.weekly
return (
<View key={account.id}>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, account.id)}
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle} numberOfLines={1}>
{account.email}
</Text>
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={session?.usedPercent ?? null}
unavailable={!session && !isFetching}
loading={isFetching && !session}
/>
<UsageBar
label="7d"
usedPercent={weekly?.usedPercent ?? null}
unavailable={!weekly && !isFetching}
loading={isFetching && !weekly}
/>
</View>
{usage?.error ? (
<Text style={styles.errorText} numberOfLines={1}>
{usage.error}
</Text>
) : null}
</View>
<View style={styles.rowTrailing}>
{isActive ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === account.id ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</Pressable>
</View>
)
})}
</View>
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.titleWrap}>
<Text style={styles.heading}>Accounts</Text>
{hostName ? (
<Text style={styles.subheading} numberOfLines={1}>
{hostName}
</Text>
) : null}
</View>
<Pressable
style={styles.iconButton}
onPress={refresh}
disabled={!client || refreshing || connState !== 'connected'}
>
{refreshing ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<RefreshCw size={18} color={colors.textSecondary} />
)}
</Pressable>
</View>
<ScrollView
contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + spacing.xl }]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
tintColor={colors.textSecondary}
/>
}
>
{connState !== 'connected' && !snapshot ? (
<View style={styles.placeholder}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.placeholderText}>Connecting to {hostName || 'host'}…</Text>
</View>
) : error && !snapshot ? (
<View style={styles.placeholder}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : !snapshot ? (
<View style={styles.placeholder}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.placeholderText}>Loading accounts…</Text>
</View>
) : (
<>
{renderProviderSection('claude', 'Claude')}
{renderProviderSection('codex', 'Codex')}
<View style={styles.footerHint}>
<User size={14} color={colors.textMuted} />
<Text style={styles.footerHintText}>
Add or re-authenticate accounts from desktop Settings → Accounts.
</Text>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+171 -69
View File
@@ -24,11 +24,23 @@ import {
Plus,
Moon,
Filter,
Check
Check,
UserCircle
} from 'lucide-react-native'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
} from '../../../src/transport/client-context'
import {
classifyConnection,
type ConnectionVerdict
} from '../../../src/transport/connection-health'
import type { RpcSuccess } from '../../../src/transport/types'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import { StatusDot } from '../../../src/components/StatusDot'
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
@@ -37,8 +49,10 @@ import { PickerModal, type PickerOption } from '../../../src/components/PickerMo
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { colors, spacing, typography } from '../../../src/theme/mobile-theme'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
import {
loadPinnedIds,
savePinnedIds,
@@ -46,6 +60,15 @@ import {
savePreferences
} from '../../../src/storage/preferences'
// Why: locally-typed subset of the desktop's RuntimeStatus we read from
// `status.get`. Only the version fields matter to mobile today; everything
// else is opaque. Both fields are optional since pre-PR desktops won't
// return them — the compat evaluator handles undefined gracefully.
type DesktopStatus = {
protocolVersion?: number
minCompatibleMobileVersion?: number
}
type Worktree = {
worktreeId: string
repo: string
@@ -75,13 +98,8 @@ type FilterState = {
selectedRepos: Set<string>
}
const STATUS_LABELS: Record<ConnectionState, string> = {
connecting: 'Connecting…',
handshaking: 'Securing…',
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
'auth-failed': 'Auth failed'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
}
const SORT_OPTIONS: PickerOption<SortMode>[] = [
@@ -244,13 +262,19 @@ export default function HostScreen() {
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
const [client, setClient] = useState<RpcClient | null>(null)
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
@@ -302,14 +326,17 @@ export default function HostScreen() {
}
}, [hostId])
// Why: keep clientRef in sync so existing imperative call sites work
// unchanged. Also re-seed the cached worktree list on hostId change
// since the useState initializer only runs on first mount.
useEffect(() => {
clientRef.current = client
}, [client])
useEffect(() => {
let disposed = false
let rpcClient: RpcClient | null = null
clientRef.current = null
setClient(null)
setConnState('connecting')
setHostName('')
setError('')
setCompatVerdict({ kind: 'ok' })
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
@@ -323,45 +350,20 @@ export default function HostScreen() {
setWorktrees([])
setLastKnownWorktrees([])
}
// Why: defer the RPC connection until after the navigation animation
// completes. Without this, connect() and loadHosts() block the JS
// thread during mount, delaying the screen transition by ~200-400ms.
// With cached worktrees the user sees content instantly; the live
// connection starts once the animation settles.
const rafId = requestAnimationFrame(() => {
if (disposed) return
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host || disposed) {
if (!host && !disposed) setError('Host not found')
return
}
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (!disposed) setConnState(state)
})
if (disposed) {
rpcClient.close()
rpcClient = null
return
}
setHostName(host.name)
clientRef.current = rpcClient
setClient(rpcClient)
await updateLastConnected(host.id)
})()
})
return () => {
disposed = true
cancelAnimationFrame(rafId)
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
if (!hostId) return
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
setHostName(host.name)
void updateLastConnected(host.id)
})
return () => {
stale = true
}
}, [hostId])
@@ -419,6 +421,47 @@ export default function HostScreen() {
}
}, [connState, fetchWorktrees])
// Why: read desktop's protocol version from status.get on every connect
// and re-evaluate compatibility. If the desktop declares this mobile
// build too old (or vice versa via the local minimum), the host detail
// screen swaps to a hard-block screen instead of the worktree list.
// Today's compat constants are wide-open so this never blocks; the wire
// format is in place to flip a switch in a future release.
useEffect(() => {
if (connState !== 'connected' || !client) return
let cancelled = false
const requestClient = client
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled || clientRef.current !== requestClient) return
if (!response.ok) return
const status = (response as RpcSuccess).result as DesktopStatus
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
setCompatVerdict(verdict)
if (verdict.kind === 'blocked') {
// Why: deterministic breadcrumb so support can confirm a block
// actually fired (vs a render bug). No PII — just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
requiredMobileVersion: verdict.requiredMobileVersion,
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: rare path — sendRequest can throw on transport tear-down.
// Treat as transient; verdict stays at previous value.
}
})()
return () => {
cancelled = true
}
}, [connState, client])
useEffect(() => {
if (connState !== 'connected') return
const interval = setInterval(() => {
@@ -498,9 +541,14 @@ export default function HostScreen() {
const handleRemoveHost = useCallback(async () => {
if (!hostId) return
// Why: close the shared client first so its WebSocket is gone before
// the host record disappears; otherwise the next loadHosts() the
// provider does (e.g. on remount) wouldn't find this host but the
// socket would still be open, leaking state.
closeHostClient(hostId)
await removeHost(hostId)
router.back()
}, [hostId, router])
}, [hostId, router, closeHostClient])
const openWorktreeSession = useCallback(
(item: Worktree) => {
@@ -631,6 +679,10 @@ export default function HostScreen() {
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
@@ -638,15 +690,45 @@ export default function HostScreen() {
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.hostIdentity}>
<StatusDot state={connState} />
<Text style={styles.hostNameText} numberOfLines={1}>
{hostName || 'Host'}
</Text>
</View>
{connState !== 'connected' && (
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
)}
{(() => {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
})
return (
<>
<View style={styles.hostIdentity}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.hostNameText} numberOfLines={1}>
{hostName || 'Host'}
</Text>
</View>
{connState !== 'connected' &&
(() => {
// Why: status label removed in favor of just the dot +
// Reconnect button — the home screen already surfaces the
// verdict text per host, and the dot color already
// signals severity here. Auth-failed routes through its
// dedicated banner so we still want to suppress the
// Reconnect button for that case.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
if (!showReconnectButton) return null
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)
})()}
</>
)
})()}
</View>
{/* Filter/sort/group toolbar */}
@@ -682,6 +764,17 @@ export default function HostScreen() {
<View style={styles.toolbarSpacer} />
<Pressable
style={styles.searchToggle}
onPress={() => router.push(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.newButton}
onPress={() => setShowNewWorktree(true)}
@@ -1086,9 +1179,18 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary
},
statusText: {
color: colors.textSecondary,
fontSize: typography.metaSize
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
authBanner: {
backgroundColor: colors.bgPanel,
+557 -133
View File
@@ -1,4 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Animated, AppState, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
View,
Text,
@@ -15,12 +17,20 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native'
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { useHostClient } from '../../../../src/transport/client-context'
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
import { triggerMediumImpact } from '../../../../src/platform/haptics'
import {
triggerMediumImpact,
triggerSelection,
triggerSuccess,
triggerError,
triggerEdgeBump
} from '../../../../src/platform/haptics'
import {
TerminalWebView,
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { StatusDot } from '../../../../src/components/StatusDot'
@@ -48,15 +58,22 @@ type TerminalCreateResult = {
type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
type AccessoryKey = { label: string; bytes: string; accessibilityLabel?: string }
type AccessoryKey = {
label: string
bytes: string
accessibilityLabel?: string
repeatable?: boolean
}
const ACCESSORY_KEYS: AccessoryKey[] = [
{ label: 'Esc', bytes: '\x1b' },
{ label: 'Tab', bytes: '\t' },
{ label: '↑', bytes: '\x1b[A' },
{ label: '↓', bytes: '\x1b[B' },
{ label: '←', bytes: '\x1b[D' },
{ label: '→', bytes: '\x1b[C' },
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
{ label: '↑', bytes: '\x1b[A', repeatable: true },
{ label: '↓', bytes: '\x1b[B', repeatable: true },
{ label: '←', bytes: '\x1b[D', repeatable: true },
{ label: '→', bytes: '\x1b[C', repeatable: true },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
@@ -81,12 +98,22 @@ function TerminalPaneView({
handle,
active,
onRef,
onWebReady
onWebReady,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onHaptic
}: {
handle: string
active: boolean
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
onWebReady: (handle: string) => void
onSelectionMode: (handle: string, active: boolean) => void
onSelectionCopy: (handle: string, text: string) => void
onSelectionEvicted: (handle: string) => void
onModesChanged: (handle: string, modes: TerminalModes) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
}) {
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
@@ -104,6 +131,11 @@ function TerminalPaneView({
ref={setRef}
style={styles.terminalWebView}
onWebReady={() => onWebReady(handle)}
onSelectionMode={(a) => onSelectionMode(handle, a)}
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
onSelectionEvicted={() => onSelectionEvicted(handle)}
onModesChanged={(m) => onModesChanged(handle, m)}
onHaptic={onHaptic}
/>
</View>
)
@@ -123,8 +155,9 @@ export default function SessionScreen() {
}>()
const router = useRouter()
const insets = useSafeAreaInsets()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const [terminals, setTerminals] = useState<Terminal[]>([])
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
@@ -144,6 +177,14 @@ export default function SessionScreen() {
// Why: server-authoritative display mode per terminal. The runtime is the
// single source of truth — this state is populated from subscribe responses.
const [terminalModes, setTerminalModes] = useState<Map<string, MobileDisplayMode>>(new Map())
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const toastOpacityRef = useRef(new Animated.Value(0))
// Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
// change so paste reads a synchronous snapshot — no round-trip required.
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
// Why: measured once from TerminalWebView on mount, then passed with every
@@ -161,6 +202,13 @@ export default function SessionScreen() {
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
// Why: server-side layout state machine emits a monotonic seq on every
// applyLayout. Track the highest seq we've observed per handle and drop
// any scrollback/resized event with a strictly older seq — these are
// late-arriving events from a superseded layout (e.g. phone-fit dims
// landing after the user toggled to desktop). Drops below `>20`-window
// gap reset (treat as a fresh subscription, e.g. server restart).
const layoutSeqRef = useRef<Map<string, number>>(new Map())
const sendingRef = useRef(false)
// Why: tracks the pixel height of the terminal frame so measureFitDimensions
// can use the exact container height instead of relying on window.innerHeight,
@@ -178,6 +226,10 @@ export default function SessionScreen() {
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
}, [])
const clearTerminalCache = useCallback(() => {
@@ -189,6 +241,7 @@ export default function SessionScreen() {
initializedHandlesRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
for (const term of terminalRefs.current.values()) {
term.clear()
}
@@ -224,10 +277,6 @@ export default function SessionScreen() {
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
console.log(
`[mobile-fit] subscribeToTerminal handle=${handle} seq=${seq} viewport=${viewportRef.current ? `${viewportRef.current.cols}x${viewportRef.current.rows}` : 'none'} measured=${viewportMeasuredRef.current}`
)
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
// The viewport is embedded in the subscribe params so the server resizes
// the PTY before serializing scrollback. This eliminates the focus→safeFit
@@ -242,43 +291,118 @@ export default function SessionScreen() {
(result) => {
if (subscribeSeqRef.current.get(handle) !== seq) return
const data = result as Record<string, unknown>
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
// for this handle — they're late-arriving from a superseded
// layout. `scrollback` is the response to a fresh subscribe,
// so it always resets the high-water mark regardless of seq
// (post-WS-reconnect or post-resubscribe the server may emit
// scrollback at a seq lower than what we'd seen pre-reconnect;
// dropping it would leave the user with a blank terminal).
const eventSeq = typeof data.seq === 'number' ? data.seq : null
if (eventSeq != null && data.type === 'resized') {
const last = layoutSeqRef.current.get(handle)
if (last != null && eventSeq < last && last - eventSeq <= 20) {
console.log('[fit][session] DROP-stale-seq', {
handle: handle.slice(-8),
type: data.type,
eventSeq,
lastSeq: last,
cols: data.cols,
rows: data.rows,
displayMode: data.displayMode
})
return
}
layoutSeqRef.current.set(handle, eventSeq)
} else if (eventSeq != null && data.type === 'scrollback') {
layoutSeqRef.current.set(handle, eventSeq)
}
if (data.type === 'scrollback') {
console.log(
`[mobile-fit] scrollback handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} hasSerialized=${!!data.serialized} alreadyInit=${initializedHandlesRef.current.has(handle)}`
)
if (initializedHandlesRef.current.has(handle)) return
if (initializedHandlesRef.current.has(handle)) {
return
}
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
const scrollbackRows = rows
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
getTerminalRef(handle)?.init(cols, rows, initialData)
const ref = getTerminalRef(handle)
// Why: previously we set `initializedHandlesRef` even when the
// WebView wasn't mounted yet (ref=null). The init message went
// nowhere, but the flag stayed true, so any subsequent scrollback
// for THIS handle was silently dropped → blank terminal. Only
// mark initialized if init() actually reached the WebView.
if (!ref) {
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
handle: handle.slice(-8),
cols,
rows
})
return
}
ref.init(cols, rows, initialData)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
)
}
// Why: cold-start fit-to-screen guard. The first init() runs
// before xterm's DOM/canvas has fully laid out, so the
// applyFitScale that init queues internally can land while
// term.element.scrollWidth is still stale or zero — leaving
// the terminal un-zoomed until the user toggles the resize
// button. Re-fire resetZoom after a short delay so it runs
// against a settled DOM. Mirrors the 'resized' handler below.
// Why: belt-and-suspenders cold-start fit. The applyFitScale
// queued by init() runs after writes drain, but on cold start
// xterm's scrollWidth can still be transient when it commits.
// Re-fire after a short delay so it runs against a settled DOM.
// Mirrors the 'resized' handler below.
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: viewport measurement needs xterm to be initialized (cell
// dimensions come from the renderer). On the first subscribe the
// WebView hasn't loaded yet, so viewportRef is null and the server
// can't auto-fit. After the first init we can measure, then
// resubscribe so the server gets the viewport and phone-fits.
if (!viewportMeasuredRef.current) {
// If viewport was measured by a parallel path BUT the scrollback
// we just received came back at desktop dims, our subscribe
// beat the measure; the server still has a null viewport for
// this subscriber record — resubscribe so it gets stored.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
(scrollbackCols !== viewportRef.current.cols ||
scrollbackRows !== viewportRef.current.rows))
if (needsResubscribe) {
void (async () => {
// Why: wait for the WebView's init() rAF chain to fully
// run (term.open → renderService population → first
// paint) before measuring. Without this, the measure
// postMessage races ahead of init's async work and
// returns null (term not ready / cells size 0), the
// resubscribe never fires, and the server never gets
// phone dims. See log dump 2026-05-06 confirming the
// race + measure-result null pattern.
await getTerminalRef(handle)?.awaitReady()
if (subscribeSeqRef.current.get(handle) !== seq) return
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
if (dims && !viewportMeasuredRef.current) {
// Why: re-check seq after the awaits — awaitReady (up to
// 3s) and measureFitDimensions can take hundreds of ms,
// during which a newer subscribe cycle may have armed
// its own subscription. Tearing it down here would reset
// the freshly-armed initialized flag and re-subscribe a
// stale generation.
if (subscribeSeqRef.current.get(handle) !== seq) return
if (!getTerminalRef(handle)) return
// Why: we just got `scrollback` with cols=80 (server's
// default fallback for null viewport). That means the
// server-side subscriber record was registered before we
// could send viewport. Even if `viewportMeasuredRef`
// raced ahead via a parallel `measureViewportOnce`, the
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
@@ -288,11 +412,28 @@ export default function SessionScreen() {
})()
}
} else if (data.type === 'data') {
getTerminalRef(handle)?.write(data.chunk as string)
// Why: log when data arrives but the WebView ref is missing
// — this is the most likely cause of "blank but input works":
// server stream is alive, sends flow, but writes are dropped
// because the WebView ref disappeared (unmount mid-flight) or
// the scrollback never landed (so xterm has no buffer).
const dataRef = getTerminalRef(handle)
if (!dataRef) {
console.log('[fit][session] data DROPPED — no terminal ref', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0,
initialized: initializedHandlesRef.current.has(handle)
})
return
}
if (!initializedHandlesRef.current.has(handle)) {
console.log('[fit][session] data RECEIVED before scrollback', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0
})
}
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
console.log(
`[mobile-fit] resized handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} reason=${(data as Record<string, unknown>).reason}`
)
// Why: inline resize event — the server changed the PTY dimensions
// (mode toggle or desktop restore). Reinitialize xterm at the new
// dims with fresh scrollback. No resubscribe needed.
@@ -332,17 +473,24 @@ export default function SessionScreen() {
if (!client) return
if (toggleInFlightRef.current.has(handle)) return
const current = terminalModes.get(handle) ?? 'auto'
const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
// Why: 'phone' on the wire is an observation ("currently phone-fitted"),
// not a setting. The toggle only ever requests 'auto' or 'desktop'.
const next: 'auto' | 'desktop' =
current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
toggleInFlightRef.current.add(handle)
try {
await client.sendRequest('terminal.setDisplayMode', {
terminal: handle,
mode: next,
// Why: presence-lock take-floor signal. Sending mode=auto/phone
// is a deliberate "I want to drive at phone dims" gesture.
// Why: presence-lock take-floor signal — requesting 'auto' is the
// explicit "I want to drive at phone dims" gesture.
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
: {}),
// Why: late-bind viewport for terminals whose subscribe record
// was registered before measurement landed. Without this the
// server's stored viewport is null and auto toggles no-op.
...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})
})
} catch {
// Mode change failed — server state unchanged, UI stays in sync.
@@ -366,9 +514,6 @@ export default function SessionScreen() {
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
console.log(
`[mobile-fit] fetchTerminals count=${result.terminals.length} allowEmpty=${allowEmptyLoaded} activeHandle=${activeHandleRef.current} lastKnown=${lastKnownTerminalCountRef.current}`
)
if (result.terminals.length === 0 && !allowEmptyLoaded) {
return
@@ -380,9 +525,6 @@ export default function SessionScreen() {
// rapid interactions while still allowing genuine cleanup.
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
lastKnownTerminalCountRef.current = 0
console.log(
`[mobile-fit] fetchTerminals SKIP first empty — will clear on next fetch if still empty`
)
return
}
@@ -397,7 +539,19 @@ export default function SessionScreen() {
lastKnownTerminalCountRef.current = result.terminals.length
const current = activeHandleRef.current
setTerminals(result.terminals)
// Why: defense-in-depth dedupe. If the server ever returns a list
// with the same handle twice (race during rename/split, or stale
// process tracking), React would throw 'two children with same
// key' on render. Keep the first occurrence — list order matters
// for the tab strip, and createParams puts new tabs at the end.
const seen = new Set<string>()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) return false
seen.add(t.handle)
return true
})
setTerminals(deduped)
setTerminalsLoaded(true)
if (!current || !result.terminals.some((t) => t.handle === current)) {
@@ -419,34 +573,40 @@ export default function SessionScreen() {
[client, worktreeId, subscribeToTerminal, unsubscribeTerminal]
)
// Why: keep clientRef in sync with the shared client from
// useHostClient() so the existing imperative call sites
// (clientRef.current.sendRequest...) keep working without churn.
useEffect(() => {
let disposed = false
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host || disposed) return
deviceTokenRef.current = host.deviceToken
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
if (disposed) {
rpcClient.close()
return
}
setClient(rpcClient)
clientRef.current = rpcClient
})()
clientRef.current = client
}, [client])
// Why: only clear terminal cache on actual unmount. Running it whenever
// `client` changes — including the initial null → real-client transition
// from useHostClient's async open path — would unsubscribe terminals and
// wipe xterm state mid-subscribe on a normal session-screen mount.
useEffect(() => {
return () => {
disposed = true
clearTerminalCache()
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
}
}
}, [clearTerminalCache, hostId])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Why: deviceToken is read from host record so feature code can pass
// `client.id` on subscribe/send for driver-state-machine identity.
// The shared client itself stays alive across screens; we just need
// the token alongside the client.
useEffect(() => {
if (!hostId) return
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (host) deviceTokenRef.current = host.deviceToken
})
return () => {
stale = true
}
}, [hostId])
useEffect(() => {
void loadCustomKeys().then(setCustomKeys)
@@ -456,71 +616,88 @@ export default function SessionScreen() {
// doesn't resize the window) and refit xterm once the layout settles so the
// terminal grid matches the new visible area. iOS exposes 'will' events that
// animate in sync with the IME; Android only fires 'did' events reliably.
useEffect(() => {
let refitTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRefit = () => {
if (refitTimer) clearTimeout(refitTimer)
refitTimer = setTimeout(() => {
const handle = activeHandleRef.current
if (!handle) return
const ref = terminalRefs.current.get(handle)
if (!ref) return
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!dims) return
const prev = viewportRef.current
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) return
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). The 'resized' event from the server reinits
// the xterm at the new dims via the existing subscription
// stream. Falls back to the unsubscribe/subscribe path if the
// RPC isn't available (older host build) or no client is
// connected. See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (response.ok) {
return
}
} catch {
// Fall through to legacy resubscribe.
}
// Also drives re-measurement when other layout-affecting state changes
// (e.g. tab strip toggling visibility when the terminal count crosses
// 0↔1 — without this, a freshly-created 2nd tab subscribes with a
// stale viewport that doesn't account for the now-visible tab strip,
// and the server phone-fits to dims a few rows too tall).
const refitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) clearTimeout(refitTimerRef.current)
refitTimerRef.current = setTimeout(() => {
const handle = activeHandleRef.current
if (!handle) return
const ref = terminalRefs.current.get(handle)
if (!ref) return
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!dims) return
const prev = viewportRef.current
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) return
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (response.ok) return
} catch {
// Fall through to legacy resubscribe.
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}, [subscribeToTerminal, unsubscribeTerminal])
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
setKeyboardHeight(e.endCoordinates?.height ?? 0)
scheduleRefit()
scheduleViewportRefit()
}
const onHide = () => {
setKeyboardHeight(0)
scheduleRefit()
scheduleViewportRefit()
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const showSub = Keyboard.addListener(showEvent, onShow)
const hideSub = Keyboard.addListener(hideEvent, onHide)
return () => {
if (refitTimer) clearTimeout(refitTimer)
if (refitTimerRef.current) clearTimeout(refitTimerRef.current)
showSub.remove()
hideSub.remove()
}
}, [subscribeToTerminal, unsubscribeTerminal])
}, [scheduleViewportRefit])
// Why: the tab strip is hidden when only one terminal exists and shown
// once a second is created. Crossing the 1↔2 boundary changes the
// visible terminal area by ~40px, so the cached viewport dims in
// viewportRef become stale. Mark the viewport as un-measured so the
// next subscribe path's self-correcting loop (init → measure →
// resubscribe-with-fresh-viewport, see the !viewportMeasuredRef branch
// above) re-runs against the new layout. Also schedule an explicit
// refit to cover the case where no new subscribe is happening.
const tabStripVisible = terminals.length > 1
const prevTabStripVisibleRef = useRef(tabStripVisible)
useEffect(() => {
if (prevTabStripVisibleRef.current === tabStripVisible) return
prevTabStripVisibleRef.current = tabStripVisible
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [tabStripVisible, scheduleViewportRefit])
useEffect(() => {
if (hostId && worktreeId) {
@@ -612,9 +789,6 @@ export default function SessionScreen() {
const switchTab = useCallback(
(handle: string) => {
const prev = activeHandleRef.current
console.log(
`[mobile-fit] switchTab prev=${prev} next=${handle} hasUnsub=${terminalUnsubsRef.current.has(handle)} hasRef=${!!terminalRefs.current.get(handle)}`
)
activeHandleRef.current = handle
setActiveHandle(handle)
if (prev && prev !== handle) {
@@ -641,9 +815,6 @@ export default function SessionScreen() {
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
if (ref) {
terminalRefs.current.set(handle, ref)
console.log(
`[mobile-fit] setTerminalWebViewRef handle=${handle} isActive=${handle === activeHandleRef.current} webReady=${webReadyHandlesRef.current.has(handle)}`
)
} else {
terminalRefs.current.delete(handle)
}
@@ -653,15 +824,13 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
console.log(
`[mobile-fit] handleTerminalWebReady handle=${handle} isActive=${handle === activeHandleRef.current} wasAlreadyReady=${wasAlreadyReady} wasInitialized=${initializedHandlesRef.current.has(handle)}`
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: the native WebView reloaded (Metro hot reload or Android
// process churn). The old xterm buffer is gone, so force a fresh
// scrollback snapshot. Only resubscribe if this is a reload — on
// first load the subscription is already running and pendingMessages
// will flush the queued init after this callback returns.
// (unsubscribeTerminal also clears layoutSeqRef for this handle.)
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
if (handle === activeHandleRef.current) {
@@ -672,10 +841,18 @@ export default function SessionScreen() {
// Why: on first web-ready, the initial subscribeToTerminal call from
// fetchTerminals may have been skipped (reason=no-ref, WebView wasn't
// mounted yet). Now that the WebView is ready, subscribe if this is the
// active terminal and no subscription is running.
// active terminal and no subscription is running. Await measure before
// subscribe so the very first subscribe carries the viewport — without
// this, subscribe(viewport=null) lands on the server first and the
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
// (because measureViewportOnce won the race) and silently skips.
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
void measureViewportOnce(handle)
subscribeToTerminal(handle)
void (async () => {
await measureViewportOnce(handle)
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
subscribeToTerminal(handle)
}
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
@@ -724,6 +901,187 @@ export default function SessionScreen() {
}
}
// Why: press-and-hold key repeat for keys flagged repeatable (arrows,
// backspace, forward-delete). Matches iOS keyboard cadence: instant first
// fire, then ~400ms before the second, then ~45ms between subsequent
// repeats. Non-repeatable keys (Tab, Esc, Ctrl-*) intentionally fire once
// because holding them is destructive or meaningless.
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Why: hold the latest handleAccessoryKey in a ref so the repeat interval
// always invokes the current callback. Otherwise a held key keeps firing
// through the callback captured when the interval started, which can route
// bytes to a stale terminal/RPC client after a tab switch or reconnect
// mid-hold.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
}, [])
const startAccessoryRepeat = useCallback(
(bytes: string) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(bytes)
}, 45)
}, 400)
},
[stopAccessoryRepeat]
)
useEffect(() => {
return () => stopAccessoryRepeat()
}, [stopAccessoryRepeat])
const showToast = useCallback((message: string, durationMs = 1200) => {
setToastMessage(message)
Animated.timing(toastOpacityRef.current, {
toValue: 1,
duration: 150,
useNativeDriver: true
}).start(() => {
setTimeout(() => {
Animated.timing(toastOpacityRef.current, {
toValue: 0,
duration: 200,
useNativeDriver: true
}).start(() => setToastMessage(null))
}, durationMs)
})
}, [])
const handleSelectionMode = useCallback((handle: string, active: boolean) => {
if (handle !== activeHandleRef.current) return
setSelectModeActive(active)
if (active) Keyboard.dismiss()
}, [])
const handleSelectionCopy = useCallback(
async (handle: string, text: string) => {
if (handle !== activeHandleRef.current) return
if (!text || text.length === 0) {
terminalRefs.current.get(handle)?.cancelSelect()
return
}
try {
await Clipboard.setStringAsync(text)
triggerSuccess()
// Why: Android 13+ shows its own system "Copied to clipboard" toast on
// every clipboard write, so our toast would be redundant; iOS shows
// nothing on copy (it only banners on paste), so the in-app toast is
// the only success signal there.
if (Platform.OS === 'ios') showToast('Copied')
terminalRefs.current.get(handle)?.cancelSelect()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
// eslint-disable-next-line no-console
console.warn('[mobile-clip] setString failed', {
name: err.name,
message: err.message
})
showToast("Couldn't copy", 1500)
}
},
[showToast]
)
const handleSelectionEvicted = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) return
// eslint-disable-next-line no-console
console.warn('[mobile-clip] selection evicted')
showToast('Selection cleared (scrolled out of buffer)', 1500)
setSelectModeActive(false)
},
[showToast]
)
const handleModesChanged = useCallback((handle: string, modes: TerminalModes) => {
ptyModesRef.current.set(handle, modes)
initialModesSeenRef.current.add(handle)
}, [])
const handleHaptic = useCallback((kind: 'selection' | 'success' | 'error' | 'edge-bump') => {
if (kind === 'selection') triggerSelection()
else if (kind === 'success') triggerSuccess()
else if (kind === 'error') triggerError()
else if (kind === 'edge-bump') triggerEdgeBump()
}, [])
const handlePaste = useCallback(async () => {
if (!client || !activeHandle || !canSend) return
try {
const text = await Clipboard.getStringAsync()
if (text.length === 0) return
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
// Why: strip embedded bracketed-paste markers from clipboard text so a
// malicious copy containing `\x1b[201~` can't terminate paste mode early
// and have the trailing bytes interpreted as shell commands. Matches
// xterm.js / iTerm2 behavior.
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
const payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
triggerError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
triggerSelection()
void Clipboard.hasStringAsync().then(setCanPaste)
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) showToast('Paste failed (disconnected)', 1500)
}
}, [client, activeHandle, canSend, connState, showToast])
// Why: refresh canPaste on mount, AppState active, after paste.
useEffect(() => {
let mounted = true
const refresh = () => {
void Clipboard.hasStringAsync().then((has) => {
if (mounted) setCanPaste(has)
})
}
refresh()
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') refresh()
else if (selectModeActive && activeHandleRef.current) {
terminalRefs.current.get(activeHandleRef.current)?.cancelSelect()
}
})
return () => {
mounted = false
sub.remove()
}
}, [selectModeActive])
async function handleCreateTerminal() {
if (!client || creating) return
@@ -747,10 +1105,17 @@ export default function SessionScreen() {
}
activeHandleRef.current = created.handle
setActiveHandle(created.handle)
setTerminals((prev) => [
...prev,
{ handle: created.handle, title: created.title || 'Terminal', isActive: true }
])
setTerminals((prev) => {
// Why: guard against duplicates if a parallel fetchTerminals()
// already inserted this handle. Without this, React throws
// 'two children with the same key' when both the optimistic
// insert and a canonical refetch race during creation.
if (prev.some((t) => t.handle === created.handle)) return prev
return [
...prev,
{ handle: created.handle, title: created.title || 'Terminal', isActive: true }
]
})
subscribeToTerminal(created.handle)
setTimeout(() => void fetchTerminals(), 500)
} else {
@@ -950,8 +1315,21 @@ export default function SessionScreen() {
active={terminal.handle === activeHandle}
onRef={setTerminalWebViewRef}
onWebReady={handleTerminalWebReady}
onSelectionMode={handleSelectionMode}
onSelectionCopy={handleSelectionCopy}
onSelectionEvicted={handleSelectionEvicted}
onModesChanged={handleModesChanged}
onHaptic={handleHaptic}
/>
))}
{toastMessage && (
<Animated.View
pointerEvents="none"
style={[styles.toast, { opacity: toastOpacityRef.current }]}
>
<Text style={styles.toastText}>{toastMessage}</Text>
</Animated.View>
)}
</View>
)}
@@ -987,6 +1365,24 @@ export default function SessionScreen() {
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
Paste
</Text>
</Pressable>
)}
{ACCESSORY_KEYS.map((key) => (
<Pressable
key={key.label}
@@ -996,7 +1392,18 @@ export default function SessionScreen() {
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey(key.bytes)}
onPressIn={() => {
if (!key.repeatable) return
void handleAccessoryKey(key.bytes)
startAccessoryRepeat(key.bytes)
}}
onPressOut={() => {
if (key.repeatable) stopAccessoryRepeat()
}}
onPress={() => {
if (key.repeatable) return
void handleAccessoryKey(key.bytes)
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
<Text
@@ -1263,6 +1670,23 @@ const styles = StyleSheet.create({
terminalWebView: {
flex: 1
},
toast: {
position: 'absolute',
bottom: spacing.lg,
alignSelf: 'center',
left: 0,
right: 0,
alignItems: 'center'
},
toastText: {
backgroundColor: 'rgba(20, 22, 39, 0.92)',
color: colors.textPrimary,
fontSize: 13,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
overflow: 'hidden'
},
emptyState: {
flex: 1,
alignItems: 'center',
+1
View File
@@ -10,6 +10,7 @@ export default function HostGroupLayout() {
}}
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
</Stack>
)
+489 -132
View File
@@ -11,19 +11,38 @@ import {
GitPullRequest,
ChevronRight,
Terminal,
Plus
Plus,
RefreshCw,
PowerOff,
Edit3
} from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
import { connect, type RpcClient } from '../src/transport/rpc-client'
import type { RpcClient } from '../src/transport/rpc-client'
import {
useAllHostClients,
useCloseHost,
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
import { colors, spacing, radii } from '../src/theme/mobile-theme'
function endpointLabel(endpoint: string): string {
@@ -35,15 +54,6 @@ function endpointLabel(endpoint: string): string {
}
}
const STATUS_LABELS: Record<ConnectionState, string> = {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
handshaking: 'Connecting…',
'auth-failed': 'Auth failed'
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
@@ -78,6 +88,21 @@ function formatDuration(ms: number): string {
return `${totalMinutes}m`
}
// Why: derive a stable per-instance identity for RpcClient so the wireUp
// effect's dep key changes when forceReconnect swaps the underlying client
// for a host (without this, listeners stay attached to the closed client
// and notifications/accounts subs never re-attach).
const clientIdentities = new WeakMap<RpcClient, number>()
let nextClientIdentity = 1
function clientKey(client: RpcClient): number {
let id = clientIdentities.get(client)
if (id == null) {
id = nextClientIdentity++
clientIdentities.set(client, id)
}
return id
}
function fetchStats(
client: RpcClient,
setStats: (s: StatsSummary) => void,
@@ -102,16 +127,24 @@ function fetchWorktreeInfo(
) => void,
disposed: () => boolean
) {
const markLoaded = () => {
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null
// Why: only seed an empty zeroed entry when this host has no prior info
// at all (e.g., first ever load before any cache hydration). On a
// transient failure for a host that already has cached data, leave the
// cached entry alone so the Resume card and host-meta line don't
// momentarily flip to "0 worktrees" / disappear during reconnects.
const markLoadedIfMissing = () => {
setInfo((prev) => {
if (prev[hostId]) return prev
return {
...prev,
[hostId]: {
hostId,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null
}
}
}))
})
}
client
@@ -135,14 +168,34 @@ function fetchWorktreeInfo(
}
}))
} else {
markLoaded()
markLoadedIfMissing()
}
})
.catch(() => {
if (!disposed()) markLoaded()
if (!disposed()) markLoadedIfMissing()
})
}
function fetchAccountsSnapshot(
client: RpcClient,
hostId: string,
setSnapshots: (
updater: (prev: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
) => void,
disposed: () => boolean
) {
client
.sendRequest('accounts.list')
.then((response) => {
if (disposed()) return
if (response.ok) {
const snapshot = response.result as AccountsSnapshot
setSnapshots((prev) => ({ ...prev, [hostId]: snapshot }))
}
})
.catch(() => {})
}
// Why: repo names get a stable color derived from hashing, matching the
// host detail page's colored dots for visual consistency.
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
@@ -162,12 +215,78 @@ export default function HomeScreen() {
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const clientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
// docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
// Why: feed the loaded HostProfiles into the provider's prime cache as
// soon as we have them. This avoids a second Keychain pass inside
// openEntry on cold start (which serialised behind the first one and
// showed up as multi-second connect latency).
useEffect(() => {
if (hosts.length > 0) primeHosts(hosts)
}, [hosts, primeHosts])
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
useEffect(() => {
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
}, [allClients])
// Why: hydrate the home page from a persisted snapshot on cold-start so
// Resume + Account-usage cards paint immediately with last-known data
// instead of flashing empty for ~1s while the WebSocket reconnects.
// Stream/list responses overwrite this seed in place when they arrive.
const hydratedRef = useRef(false)
useEffect(() => {
if (hydratedRef.current) return
hydratedRef.current = true
let cancelled = false
void loadHomeSnapshot().then((snap) => {
if (cancelled || !snap) return
setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo))
setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost))
for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
const wt = info.lastActiveWorktree
if (wt) {
// Why: also seed the in-memory worktree cache so resumeWorktree's
// lastVisited fast-path can find the cached worktree object.
setCachedWorktrees(hostId, [wt])
}
}
})
return () => {
cancelled = true
}
}, [])
// Why: persist the merged snapshot whenever either piece updates so the
// next cold-start has fresh seed data. The cache module debounces writes
// internally so a flurry of streamed updates doesn't hammer disk.
useEffect(() => {
if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
return
}
saveHomeSnapshot({
worktreeInfo,
accountsByHost,
savedAt: Date.now()
})
}, [worktreeInfo, accountsByHost])
useFocusEffect(
useCallback(() => {
@@ -181,10 +300,11 @@ export default function HomeScreen() {
setLastVisited(JSON.parse(raw))
} catch {}
})
for (const entry of clientsRef.current) {
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
}
}
return () => {
@@ -198,68 +318,156 @@ export default function HomeScreen() {
[hosts]
)
// Why: mirror per-host connection state into hostStates so existing
// render code (status dots, connecting indicators) keeps working.
useEffect(() => {
let disposed = false
const notifCleanups: Array<() => void> = []
const entries = hosts.flatMap((host) => {
if (!host.publicKeyB64 || !host.deviceToken) {
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
return []
setHostAttempts((prev) => {
const next: Record<string, number> = { ...prev }
let changed = false
for (const entry of allClients) {
const a = entry.client.getReconnectAttempt()
if (next[entry.hostId] !== a) {
next[entry.hostId] = a
changed = true
}
}
setHostStates((prev) => ({
...prev,
[host.id]: prev[host.id] ?? 'connecting'
}))
let client: ReturnType<typeof connect>
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (disposed) return
setHostStates((prev) => ({ ...prev, [host.id]: state }))
})
} catch {
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
return []
return changed ? next : prev
})
setHostLastConnected((prev) => {
const next: Record<string, number | null> = { ...prev }
let changed = false
for (const entry of allClients) {
const t = entry.client.getLastConnectedAt()
if (next[entry.hostId] !== t) {
next[entry.hostId] = t
changed = true
}
}
return changed ? next : prev
})
setHostStates((prev) => {
const next: Record<string, ConnectionState> = { ...prev }
let changed = false
const liveIds = new Set(allClients.map((e) => e.hostId))
for (const entry of allClients) {
if (next[entry.hostId] !== entry.state) {
next[entry.hostId] = entry.state
changed = true
}
}
// Why: when a paired host disappears from allClients (because the
// user tapped Disconnect, or the host record was invalid) the card
// must reflect that. We only force-update hosts whose state was
// already tracked — otherwise the initial-acquire frame (entry not
// yet materialised) would briefly flip every host to 'disconnected'.
for (const host of hosts) {
if (liveIds.has(host.id)) continue
if (!host.publicKeyB64 || !host.deviceToken) {
if (next[host.id] !== 'auth-failed') {
next[host.id] = 'auth-failed'
changed = true
}
continue
}
const prevState = next[host.id]
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
next[host.id] = 'disconnected'
changed = true
}
}
// Drop entries for hosts we no longer track at all.
for (const id of Object.keys(next)) {
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [allClients, hosts])
// Why: per-host streaming subscriptions (notifications + accounts) and
// one-shot stats fetches when each host transitions to 'connected'.
// Runs once per (hostId, client) pair and tears down when that pair
// changes. The provider keeps the underlying socket open across
// resubscription cycles so this is cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const unsubState = client.onStateChange((state) => {
const wireUp = (state: ConnectionState) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(client)
unsubNotif = subscribeToDesktopNotifications(entry.client)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') return
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! }))
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(client, setStats, () => disposed)
fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed)
fetchStats(entry.client, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
} else if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
})
notifCleanups.push(() => {
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
cleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
return [{ hostId: host.id, client }]
})
clientsRef.current = entries
return () => {
disposed = true
clientsRef.current = []
for (const cleanup of notifCleanups) cleanup()
for (const entry of entries) entry.client.close()
}
}, [hosts])
return () => {
for (const c of cleanups) c()
}
// Why: depend on the host-id set AND each entry's client identity, so
// resubscriptions don't fire on every render that produces a new
// array reference, but DO fire when forceReconnect swaps the
// underlying client for a host (otherwise wireUp would keep firing
// on a closed client and never re-attach to the fresh one, leaving
// notifications/accounts subs broken until the user navigates).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
allClients
.map((e) => `${e.hostId}:${clientKey(e.client)}`)
.sort()
.join(',')
])
// Why: prefer the worktree the user last opened on this device so the
// "Resume" card reflects their mobile session history, not just the
// desktop's most-recently-outputting worktree.
// Why: rendering used to be gated on hostStates === 'connected', which
// caused the Resume card to vanish for ~1s on every cold-start /
// resume-from-background while the WebSocket reconnected, even though we
// had perfectly good cached worktree data. Now the card stays visible as
// long as we have a cached lastActiveWorktree for any known host; the
// tap target is still the same and a fresher snapshot from the live RPC
// overwrites the card's contents in place when it lands.
const resumeWorktree = useMemo(() => {
// Why: only surface Resume for hosts that are currently connected.
// Showing a stale cached worktree for a disconnected host is
// misleading — the user would tap into a session route that can't
// load anything until the host reconnects. Once the host reconnects,
// the card reappears with fresh data.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
@@ -275,19 +483,21 @@ export default function HomeScreen() {
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
const resumeLoading = useMemo(
() =>
sortedHosts.some((host) => {
const state = hostStates[host.id] ?? 'connecting'
return (
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting' ||
(state === 'connected' && !worktreeInfo[host.id])
)
}),
[sortedHosts, hostStates, worktreeInfo]
)
// Why: only show the Account usage section for hosts that are currently
// connected. Showing stale cached usage for a disconnected host implies
// live data; better to hide until the host reconnects and we can refresh.
const accountsHosts = useMemo(() => {
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') continue
const snap = accountsByHost[host.id]
if (!snap) continue
const hasClaude = snap.claude.accounts.length > 0
const hasCodex = snap.codex.accounts.length > 0
if (hasClaude || hasCodex) items.push({ host, snapshot: snap })
}
return items
}, [sortedHosts, hostStates, accountsByHost])
async function handleRename(newName: string) {
if (!renameTarget) return
@@ -303,6 +513,9 @@ export default function HomeScreen() {
async function handleRemove() {
if (!confirmRemove) return
try {
// Why: close the shared client first so the WebSocket is gone
// before the host record disappears from loadHosts().
closeHostClient(confirmRemove.id)
await removeHost(confirmRemove.id)
setConfirmRemove(null)
setHosts(await loadHosts())
@@ -335,12 +548,12 @@ export default function HomeScreen() {
<View style={styles.emptyHero}>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
Pair with Orca on your computer to monitor worktrees, watch agents work, and manage
terminals — all from your phone.
Pair with Orca on your computer to check on your agents, jump into any terminal, and
drive work from your phone.
</Text>
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
<QrCode size={17} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Scan Pairing Code</Text>
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
</Pressable>
</View>
@@ -408,8 +621,19 @@ export default function HomeScreen() {
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt
})
const isError =
verdict.kind === 'warning' ||
verdict.kind === 'unreachable' ||
verdict.kind === 'auth-failed'
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
@@ -434,14 +658,9 @@ export default function HomeScreen() {
{item.name}
</Text>
<View style={styles.hostMeta}>
<View
style={[
styles.statusDot,
{ backgroundColor: connected ? colors.statusGreen : colors.textMuted }
]}
/>
<Text style={styles.hostMetaItem}>
{STATUS_LABELS[state]}
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdict.label}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
@@ -490,16 +709,84 @@ export default function HomeScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</>
) : hosts.length > 0 && resumeLoading ? (
) : null}
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
<>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
<View style={styles.resumeCard}>
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
<View style={styles.resumeMain}>
<View style={[styles.skeletonLine, { width: '55%' }]} />
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
</View>
</View>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>
Account usage
</Text>
{accountsHosts.map(({ host, snapshot }) => {
const claudeActiveId = snapshot.claude.activeAccountId
const claudeActive =
snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null
const codexActiveId = snapshot.codex.activeAccountId
const codexActive =
snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null
const showHostName = accountsHosts.length > 1
return (
<Pressable
key={host.id}
style={({ pressed }) => [
styles.accountsCard,
pressed && styles.hostCardPressed
]}
onPress={() => router.push(`/h/${host.id}/accounts`)}
>
{showHostName ? (
<Text style={styles.accountsHostLabel} numberOfLines={1}>
{host.name}
</Text>
) : null}
{(['claude', 'codex'] as ProviderKey[]).map((provider) => {
const active = provider === 'claude' ? claudeActive : codexActive
const accounts =
provider === 'claude'
? snapshot.claude.accounts
: snapshot.codex.accounts
if (accounts.length === 0) return null
const limits = getActiveProviderRateLimits(snapshot, provider)
const isFetching =
limits?.status === 'fetching' || limits?.status === 'idle'
const unavailable =
limits == null ||
limits.status === 'unavailable' ||
limits.status === 'error'
return (
<View key={provider} style={styles.accountsRow}>
<View style={styles.accountsIcon}>
{provider === 'claude' ? (
<ClaudeIcon size={18} />
) : (
<OpenAIIcon size={18} color={colors.textPrimary} />
)}
</View>
<View style={styles.accountsInfo}>
<Text style={styles.accountsEmail} numberOfLines={1}>
{active?.email ?? 'System default'}
</Text>
<View style={styles.accountsBars}>
<UsageBar
label="5h"
usedPercent={limits?.session?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.session == null}
/>
<UsageBar
label="7d"
usedPercent={limits?.weekly?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.weekly == null}
/>
</View>
</View>
</View>
)
})}
</Pressable>
)
})}
</>
) : null}
@@ -511,7 +798,7 @@ export default function HomeScreen() {
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={20} color={colors.textSecondary} />
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
@@ -525,7 +812,7 @@ export default function HomeScreen() {
}}
>
<View style={styles.quickActionIcon}>
<Plus size={20} color={colors.textSecondary} />
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Worktree</Text>
</Pressable>
@@ -540,25 +827,58 @@ export default function HomeScreen() {
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
actions={[
{
label: 'Rename',
actions={(() => {
const host = actionTarget
if (!host) return []
const state = hostStates[host.id] ?? 'connecting'
const isLive =
state === 'connected' ||
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting'
// Why: "Reconnect" implies "you were connected, try again". If
// the client has never reached 'connected' this session (cold
// start, unreachable host, or after Disconnect) the action is
// functionally a fresh Connect — using the right verb makes
// the affordance match what tapping it actually does.
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
const items: ActionSheetAction[] = []
items.push({
label: hasEverConnected && isLive ? 'Reconnect' : 'Connect',
icon: RefreshCw,
onPress: () => {
const host = actionTarget
setActionTarget(null)
if (host) setRenameTarget(host)
void forceReconnectHost(host.id)
}
},
{
})
if (isLive) {
items.push({
label: 'Disconnect',
icon: PowerOff,
onPress: () => {
setActionTarget(null)
closeHostClient(host.id)
}
})
}
items.push({
label: 'Rename',
icon: Edit3,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
}
})
items.push({
label: 'Remove',
destructive: true,
onPress: () => {
const host = actionTarget
setActionTarget(null)
if (host) setConfirmRemove(host)
setConfirmRemove(host)
}
}
]}
})
return items
})()}
onClose={() => setActionTarget(null)}
/>
@@ -667,20 +987,21 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: 10,
padding: spacing.md
paddingVertical: 10,
paddingHorizontal: spacing.md
},
statIcon: {
width: 30,
height: 30,
borderRadius: 7,
width: 26,
height: 26,
borderRadius: 6,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10
marginBottom: 6
},
statValue: {
color: colors.textPrimary,
fontSize: 20,
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3
},
@@ -688,7 +1009,7 @@ const styles = StyleSheet.create({
color: colors.textMuted,
fontSize: 11,
fontWeight: '500',
marginTop: 3
marginTop: 2
},
/* ─── Section heading ─── */
@@ -817,16 +1138,51 @@ const styles = StyleSheet.create({
flex: 1
},
/* ─── Skeleton ─── */
skeletonBlock: {
backgroundColor: colors.bgRaised,
opacity: 0.5
/* ─── Account usage ─── */
accountsCard: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
gap: spacing.sm,
marginBottom: spacing.sm
},
skeletonLine: {
height: 12,
borderRadius: 4,
accountsHostLabel: {
fontSize: 11,
color: colors.textMuted,
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: 0.4
},
accountsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2
},
accountsIcon: {
width: 32,
height: 32,
borderRadius: 9,
backgroundColor: colors.bgRaised,
opacity: 0.5
alignItems: 'center',
justifyContent: 'center'
},
accountsInfo: {
flex: 1,
minWidth: 0,
gap: 2
},
accountsEmail: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
accountsBars: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
/* ─── Quick actions ─── */
@@ -836,18 +1192,20 @@ const styles = StyleSheet.create({
},
quickAction: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
padding: spacing.lg,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
gap: 10
},
quickActionIcon: {
width: 40,
height: 40,
borderRadius: 12,
width: 28,
height: 28,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center'
@@ -855,8 +1213,7 @@ const styles = StyleSheet.create({
quickActionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
textAlign: 'center'
color: colors.textSecondary
},
/* ─── Empty state ─── */
+79 -11
View File
@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useState, useCallback, useEffect } from 'react'
import { AppState, Linking, View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
@@ -8,28 +8,69 @@ import {
loadPushNotificationsEnabled,
savePushNotificationsEnabled
} from '../src/storage/preferences'
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
import {
ensureNotificationPermissions,
getNotificationPermissionState,
type NotificationPermissionState
} from '../src/notifications/mobile-notifications'
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
granted: false,
status: 'undetermined',
canAskAgain: true
}
export default function NotificationsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [pushEnabled, setPushEnabled] = useState(true)
const [pushEnabled, setPushEnabled] = useState(false)
const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE)
const refreshSettings = useCallback(async () => {
const [enabled, permission] = await Promise.all([
loadPushNotificationsEnabled(),
getNotificationPermissionState()
])
setPushEnabled(enabled)
setPermissionState(permission)
}, [])
useFocusEffect(
useCallback(() => {
void loadPushNotificationsEnabled().then(setPushEnabled)
}, [])
void refreshSettings()
}, [refreshSettings])
)
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
void refreshSettings()
}
})
return () => subscription.remove()
}, [refreshSettings])
const togglePush = async (value: boolean) => {
if (value) {
const granted = await ensureNotificationPermissions()
if (!granted) return
const permission = await getNotificationPermissionState()
setPermissionState(permission)
if (!granted) {
setPushEnabled(false)
await savePushNotificationsEnabled(false)
return
}
}
setPushEnabled(value)
await savePushNotificationsEnabled(value)
}
const switchEnabled = pushEnabled && permissionState.granted
const notificationsBlocked = permissionState.status === 'denied'
const hint = notificationsBlocked
? 'Notifications are disabled in system settings.'
: 'Receive notifications when an agent task completes on your desktop.'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
@@ -43,15 +84,25 @@ export default function NotificationsScreen() {
<View style={styles.row}>
<Text style={styles.rowLabel}>Push Notifications</Text>
<Switch
value={pushEnabled}
value={switchEnabled}
disabled={notificationsBlocked}
onValueChange={(v) => void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Text style={styles.hint}>
Receive notifications when an agent task completes on your desktop.
</Text>
<Text style={styles.hint}>{hint}</Text>
{notificationsBlocked && (
<Pressable
style={({ pressed }) => [
styles.settingsButton,
pressed && styles.settingsButtonPressed
]}
onPress={() => void Linking.openSettings()}
>
<Text style={styles.settingsButtonText}>Open Settings</Text>
</Pressable>
)}
</View>
</View>
)
@@ -105,5 +156,22 @@ const styles = StyleSheet.create({
lineHeight: 18,
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.md
},
settingsButton: {
alignSelf: 'flex-start',
marginHorizontal: spacing.md + 2,
marginBottom: spacing.md,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
settingsButtonPressed: {
opacity: 0.6
},
settingsButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { parsePairingCode } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
// Why: cap how long the user stares at "Connecting…" during pairing.
// rpc-client retries forever by design (good for live sessions), but for
// the *initial* pair we want a hard ceiling so a half-broken Tailscale
// route surfaces an actionable error with the log visible, instead of
// spinning silently. ~25s allows for one full connect-timeout + a retry.
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairConfirmScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [offer, setOffer] = useState<PairingOffer | null>(null)
const [status, setStatus] = useState<Status>('awaiting-confirm')
const [errorMessage, setErrorMessage] = useState('')
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
// Why: collect logs in a ref so the rpc-client callback (which closures
// over the initial state setter) always sees the freshest list and we
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
useEffect(() => {
if (!params.code) {
setStatus('error')
setErrorMessage('Missing pairing code')
return
}
const parsed = parsePairingCode(params.code)
if (!parsed) {
setStatus('error')
setErrorMessage('Not a valid pairing code')
return
}
setOffer(parsed)
}, [params.code])
async function confirm() {
if (!offer) return
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect" error.
let response: RpcResponse
let timedOut = false
const overallTimer = setTimeout(() => {
timedOut = true
client?.close()
}, PAIRING_OVERALL_TIMEOUT_MS)
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
onLog: (entry) => {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
clearTimeout(overallTimer)
client.close()
client = null
} catch (err) {
clearTimeout(overallTimer)
console.warn('[pair-confirm] connect failed', err)
setStatus('error')
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
client?.close()
return
}
if (!response.ok) {
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
? 'Authentication failed — token may be expired'
: `Server error: ${response.error.message}`
)
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
router.replace(`/h/${hostId}`)
} catch (err) {
console.warn('[pair-confirm] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
)
}
}
function cancel() {
router.replace('/')
}
const containerPadding = { paddingTop: insets.top + spacing.sm }
return (
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={cancel}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.content}>
{offer && status === 'awaiting-confirm' && (
<>
<Text style={styles.title}>Pair with this desktop?</Text>
<Text style={styles.subtitle}>
You opened a pairing link from your desktop. Confirm to add it to your hosts.
</Text>
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
<Text style={styles.primaryButtonText}>Pair</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={cancel}>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</Pressable>
</>
)}
{status === 'connecting' && (
<>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting…</Text>
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
</>
)}
{status === 'error' && (
<>
<Text style={styles.errorText}>{errorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
)}
<Pressable style={styles.primaryButton} onPress={cancel}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: spacing.sm,
// Why: nudges the centered group slightly above the geometric
// middle so the eye reads it as visually centered above the home
// indicator / nav bar.
paddingBottom: spacing.xl * 2
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm,
textAlign: 'center'
},
subtitle: {
fontSize: typography.bodySize,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.xl,
textAlign: 'center'
},
primaryButton: {
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center',
marginBottom: spacing.sm
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg,
textAlign: 'center'
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
marginBottom: spacing.md
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
}
})
+231 -43
View File
@@ -1,14 +1,21 @@
import { useState, useRef, useCallback } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { decodePairingUrl } from '../src/transport/pairing'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { PairingOffer } from '../src/transport/types'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
// silent infinite spinner.
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
function Step({ number, text }: { number: number; text: string }) {
return (
@@ -27,6 +34,9 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
const [errorMessage, setErrorMessage] = useState('')
const [pasteVisible, setPasteVisible] = useState(false)
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const handleBarCodeScanned = useCallback(
@@ -47,32 +57,79 @@ export default function PairScanScreen() {
[router]
)
const handlePasteSubmit = useCallback((input: string) => {
setPasteVisible(false)
if (processingRef.current) return
processingRef.current = true
const offer = parsePairingCode(input)
if (!offer) {
setStatus('error')
setErrorMessage('Not a valid pairing code — copy it from your computer and paste again')
processingRef.current = false
return
}
void testAndSave(offer)
}, [])
async function testAndSave(offer: PairingOffer) {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect — same network?" error. Pairing reached the
// desktop fine; the failure is local persistence.
let response: RpcResponse
let timedOut = false
const overallTimer = setTimeout(() => {
timedOut = true
client?.close()
}, PAIRING_OVERALL_TIMEOUT_MS)
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
const response = await client.sendRequest('status.get')
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
onLog: (entry) => {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
clearTimeout(overallTimer)
client.close()
client = null
} catch (err) {
clearTimeout(overallTimer)
console.warn('[pair] connect failed', err)
setStatus('error')
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
processingRef.current = false
client?.close()
return
}
if (!response.ok) {
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
if (!response.ok) {
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
processingRef.current = false
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
@@ -81,24 +138,32 @@ export default function PairScanScreen() {
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
router.replace(`/h/${hostId}`)
} catch {
} catch (err) {
console.warn('[pair] save failed', err)
setStatus('error')
setErrorMessage('Cannot connect — check that your computer is on the same network')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
)
processingRef.current = false
} finally {
client?.close()
}
}
function retry() {
setStatus('scanning')
setErrorMessage('')
logsRef.current = []
setLogs([])
processingRef.current = false
}
const containerPadding = { paddingTop: insets.top + spacing.sm }
// Why: bottom inset accounts for Android 3-button nav bars and iOS
// home-indicator areas that would otherwise overlap the 'Or paste
// pairing code' button at the bottom of the scan screen.
const containerPadding = {
paddingTop: insets.top + spacing.sm,
paddingBottom: insets.bottom + spacing.sm
}
if (!permission) {
return (
@@ -109,20 +174,46 @@ export default function PairScanScreen() {
}
if (!permission.granted) {
const canAskAgain = permission.canAskAgain !== false
return (
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.centered}>
<Text style={styles.title}>Camera Permission</Text>
<Text style={styles.subtitle}>
Orca needs camera access to scan the pairing QR code from your desktop.
<Text style={styles.title}>
{canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'}
</Text>
<Pressable style={styles.primaryButton} onPress={requestPermission}>
<Text style={styles.primaryButtonText}>Grant Camera Access</Text>
<Text style={styles.subtitle}>
{canAskAgain
? 'Scan the QR code from Orca on your desktop, or paste the pairing code instead.'
: 'Enable camera access in Settings, or paste the pairing code instead.'}
</Text>
<Pressable
style={styles.primaryButton}
onPress={canAskAgain ? requestPermission : () => void Linking.openSettings()}
>
{canAskAgain && <QrCode size={16} color={colors.bgBase} />}
<Text style={styles.primaryButtonText}>
{canAskAgain ? 'Continue' : 'Open Settings'}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>Paste code instead</Text>
</Pressable>
</View>
<TextInputModal
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}
@@ -140,37 +231,85 @@ export default function PairScanScreen() {
</View>
{status === 'scanning' && (
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.reticle} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
</View>
<>
{/* Why: unmount the camera while the paste sheet is open. The
user has clearly chosen the paste path; keeping the camera
streaming behind a sheet wastes power and looks weird if
they cancel the sheet and the QR was scanned silently in
the meantime. */}
{!pasteVisible && (
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.reticle} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
</View>
)}
{pasteVisible && <View style={styles.cameraPlaceholder} />}
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>Or paste pairing code</Text>
</Pressable>
</>
)}
{status === 'connecting' && (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting…</Text>
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
</View>
)}
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Pressable style={styles.primaryButton} onPress={retry}>
<Text style={styles.primaryButtonText}>Try Again</Text>
</Pressable>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
</View>
)}
<View style={styles.errorActions}>
<Pressable style={styles.primaryButton} onPress={retry}>
<Text style={styles.primaryButtonText}>Try Again</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.pasteButtonPressed
]}
onPress={() => {
retry()
setPasteVisible(true)
}}
>
<Text style={styles.secondaryButtonText}>Paste code instead</Text>
</Pressable>
</View>
</View>
)}
<TextInputModal
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}
@@ -221,6 +360,14 @@ const styles = StyleSheet.create({
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
@@ -275,6 +422,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
@@ -286,6 +434,11 @@ const styles = StyleSheet.create({
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
@@ -294,6 +447,10 @@ const styles = StyleSheet.create({
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
@@ -303,5 +460,36 @@ const styles = StyleSheet.create({
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+41 -2
View File
@@ -1,7 +1,16 @@
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Info, Bell, Wrench } from 'lucide-react-native'
import {
ChevronLeft,
ChevronRight,
Info,
Bell,
Wrench,
Shield,
LifeBuoy,
Terminal as TerminalIcon
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
export default function SettingsScreen() {
@@ -18,6 +27,15 @@ export default function SettingsScreen() {
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/terminal-settings')}
>
<TerminalIcon size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Terminal</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
@@ -45,6 +63,24 @@ export default function SettingsScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<View style={[styles.section, styles.sectionSpacer]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
>
<Shield size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Privacy Policy</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
>
<LifeBuoy size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Support</Text>
</Pressable>
</View>
</View>
)
}
@@ -78,6 +114,9 @@ const styles = StyleSheet.create({
borderRadius: 12,
overflow: 'hidden'
},
sectionSpacer: {
marginTop: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
{ value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
{ value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
]
function valueFromMs(ms: number | null | undefined): RestoreValue {
if (ms == null) return 'indefinite'
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
if (exact) return exact.value
// Why: server may return a non-preset ms (custom value, future preset,
// or server-side clamp). Snap to the closest finite preset so the
// picker's selected radio agrees with the row sublabel rendered by
// autoRestoreSummary ("After Xs").
let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
let bestDelta = Infinity
for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
if (opt.ms == null) continue
const delta = Math.abs(opt.ms - ms)
if (delta < bestDelta) {
bestDelta = delta
closest = opt
}
}
return closest ? closest.value : 'indefinite'
}
function autoRestoreSummary(ms: number | null | undefined): string {
if (ms === undefined) return '…'
if (ms === null) return AUTO_RESTORE_FIT_OPTIONS[0]!.label
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
}
function HostFitRow({
client,
hostName,
ms,
onPress
}: {
client: RpcClient | null
hostName: string
ms: number | null | undefined
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
disabled={!client}
>
<Smartphone size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{hostName}</Text>
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState<HostProfile[]>([])
useEffect(() => {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
// absoluteFill backdrop to the ScrollView content frame and made the
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
for (const host of hosts) {
const entry = hostClients.find((e) => e.hostId === host.id)
const client = entry?.client ?? null
if (!client) continue
void client
.sendRequest('terminal.getAutoRestoreFit')
.then((resp) => {
if (cancelled) return
const value = (resp as { ms?: number | null } | null)?.ms
setHostMs((prev) => ({ ...prev, [host.id]: value === undefined ? null : value }))
})
.catch(() => {
if (!cancelled) setHostMs((prev) => ({ ...prev, [host.id]: null }))
})
}
return () => {
cancelled = true
}
}, [hosts, hostClients])
async function selectValue(hostId: string, value: RestoreValue) {
const entry = hostClients.find((e) => e.hostId === hostId)
const client = entry?.client ?? null
if (!client) return
const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
if (!opt) return
setHostMs((prev) => ({ ...prev, [hostId]: opt.ms }))
try {
const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
ms: opt.ms
})) as { ms?: number | null } | null
const finalMs = resp?.ms === undefined ? null : resp.ms
setHostMs((prev) => ({ ...prev, [hostId]: finalMs }))
} catch {
try {
const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as {
ms?: number | null
} | null
setHostMs((prev) => ({
...prev,
[hostId]: resp?.ms === undefined ? null : resp.ms
}))
} catch {
// give up silently — the next mount retries
}
}
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Terminal</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
you close the app or switch away, this controls whether it stays at phone size (so
interactive CLI tools don&apos;t reflow) or resizes back to your desktop. You can always
tap Restore on the terminal banner to resize it manually.
</Text>
{hosts.length === 0 ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>
No paired desktops yet. Pair one to control terminal behavior.
</Text>
</View>
) : (
<View style={[styles.section, styles.sectionTopGap]}>
{hosts.map((host, idx) => {
const entry = hostClients.find((e) => e.hostId === host.id)
return (
<View key={host.id}>
{idx > 0 && <View style={styles.separator} />}
<HostFitRow
client={entry?.client ?? null}
hostName={host.name}
ms={hostMs[host.id]}
onPress={() => setPickerHostId(host.id)}
/>
</View>
)
})}
</View>
)}
</ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
title={pickerHost ? `Restore ${pickerHost.name}` : ''}
options={AUTO_RESTORE_FIT_OPTIONS}
selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
onSelect={(v) => {
if (pickerHost) void selectValue(pickerHost.id, v)
}}
onClose={() => setPickerHostId(null)}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg,
paddingTop: 0
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scrollContent: {
paddingBottom: spacing.xl
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+23 -21
View File
@@ -13,32 +13,34 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"expo": "^55.0.17",
"expo": "^55.0.23",
"expo-build-properties": "^55.0.13",
"expo-camera": "^55.0.16",
"expo-constants": "^55.0.15",
"expo-camera": "^55.0.18",
"expo-clipboard": "^55.0.13",
"expo-constants": "^55.0.16",
"expo-crypto": "^55.0.14",
"expo-haptics": "^55.0.14",
"expo-linking": "^55.0.14",
"expo-notifications": "^55.0.21",
"expo-router": "^55.0.13",
"expo-splash-screen": "^55.0.19",
"expo-status-bar": "^55.0.5",
"lucide-react-native": "^1.11.0",
"react": "^19.2.0",
"react-native": "^0.83.6",
"react-native-gesture-handler": "^2.30.1",
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"expo-linking": "^55.0.15",
"expo-notifications": "^55.0.22",
"expo-router": "^55.0.14",
"expo-secure-store": "^55.0.13",
"expo-splash-screen": "^55.0.20",
"expo-status-bar": "^55.0.6",
"lucide-react-native": "^1.14.0",
"react": "^19.2.6",
"react-native": "^0.83.9",
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.0",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0",
"react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.7.4",
"react-native-worklets": "^0.8.3",
"tweetnacl": "^1.0.3",
"ws": "^8.18.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"ws": "^8.20.0",
"zod": "~4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "^19.2.14",
@@ -46,8 +48,8 @@
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"tsx": "^4.19.4",
"oxlint": "^1.63.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+1143 -1295
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
// Why: persist the data needed to render the home page so cold-start /
// resume-from-background paints instantly with the last known good
// values, then updates in place when fresh RPC data arrives. Without
// this, Resume and Account-usage cards flash empty for ~1s while the
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
const STORAGE_KEY = 'orca:home-snapshot:v1'
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
export type HomeSnapshot = {
worktreeInfo: Record<string, HostWorktreeInfo>
accountsByHost: Record<string, AccountsSnapshot>
savedAt: number
}
let memoryCache: HomeSnapshot | null = null
let writeTimer: ReturnType<typeof setTimeout> | null = null
export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
if (memoryCache) return memoryCache
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as HomeSnapshot
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof parsed.worktreeInfo !== 'object' ||
typeof parsed.accountsByHost !== 'object'
) {
return null
}
memoryCache = parsed
return parsed
} catch {
return null
}
}
// Why: throttle writes so a flurry of streamed account-snapshot updates
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
memoryCache = snapshot
if (writeTimer) clearTimeout(writeTimer)
writeTimer = setTimeout(() => {
writeTimer = null
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
}, 250)
}
+155
View File
@@ -0,0 +1,155 @@
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
claude: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
// percent used. Color thresholds invert accordingly.
export function UsageBar({
label,
usedPercent,
unavailable,
loading
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
}) {
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
const barColor =
remaining == null
? colors.textMuted
: remaining <= 10
? colors.statusRed
: remaining <= 30
? colors.statusAmber
: colors.statusGreen
return (
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${remaining ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
usageBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flex: 1
},
usageLabel: {
fontSize: typography.metaSize,
color: colors.textMuted,
width: 22
},
usageTrack: {
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.bgRaised,
overflow: 'hidden'
},
usageFill: {
height: '100%',
borderRadius: 3
},
usageValue: {
fontSize: typography.metaSize,
color: colors.textSecondary,
width: 36,
textAlign: 'right'
},
usageSpinner: {
width: 36
}
})
+30
View File
@@ -0,0 +1,30 @@
import Svg, { Path } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
// Why: SVG paths sourced from the desktop codebase
// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop
// stay visually identical for Claude/Codex branding.
export function ClaudeIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fill="#D97757"
fillRule="nonzero"
/>
</Svg>
)
}
export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
fill={color ?? colors.textPrimary}
fillRule="evenodd"
/>
</Svg>
)
}
+32 -11
View File
@@ -1,15 +1,20 @@
import { useEffect, useRef } from 'react'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
// Why: colors and sizing are 1:1 with the desktop StatusIndicator
// (src/renderer/src/components/sidebar/StatusIndicator.tsx) so the mobile
// worktree list reads identically to the sidebar — same yellow spinner for
// 'working', same emerald dot for 'active'/'done', same neutral-500 @ 40%
// for 'inactive', same red for 'permission'. Diverging palettes here lose
// the design intent ('moving' vs 'alive' vs 'completed') the desktop encodes.
const STATUS_COLORS: Record<WorktreeStatus, string> = {
working: colors.statusGreen,
active: colors.statusGreen,
permission: colors.statusRed,
done: '#38bdf8',
inactive: '#666666'
working: '#eab308',
active: '#10b981',
done: '#10b981',
permission: '#ef4444',
inactive: 'rgba(115,115,115,0.4)'
}
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
@@ -39,23 +44,39 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
outputRange: ['0deg', '360deg']
})
return (
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
</View>
)
}
return <View style={[styles.dot, { backgroundColor: color }]} />
return (
<View style={styles.wrapper}>
<View style={[styles.dot, { backgroundColor: color }]} />
</View>
)
}
const styles = StyleSheet.create({
// Why: 12x12 wrapper centered around an 8x8 inner glyph mirrors the
// desktop's `inline-flex h-3 w-3 ... items-center justify-center` shell
// around `size-2` indicator — keeps row height/baseline alignment stable
// across status transitions.
wrapper: {
width: 12,
height: 12,
alignItems: 'center',
justifyContent: 'center'
},
dot: {
width: 8,
height: 8,
borderRadius: 4
},
spinner: {
width: 10,
height: 10,
borderRadius: 5,
width: 8,
height: 8,
borderRadius: 4,
borderWidth: 2,
borderTopColor: 'transparent'
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useRef } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import type { ConnectionLogEntry } from '../transport/types'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Props = {
entries: ConnectionLogEntry[]
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
}
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
info: colors.textSecondary,
success: colors.statusGreen,
warn: colors.statusAmber,
error: colors.statusRed
}
const LEVEL_GLYPH: Record<ConnectionLogEntry['level'], string> = {
info: '•',
success: '✓',
warn: '!',
error: '✕'
}
function formatTime(ts: number, baseTs: number): string {
// Why: show elapsed seconds since the first entry — absolute wall-clock
// time isn't actionable when debugging "why is connecting stuck".
const elapsed = Math.max(0, ts - baseTs) / 1000
if (elapsed < 10) return `+${elapsed.toFixed(2)}s`
if (elapsed < 100) return `+${elapsed.toFixed(1)}s`
return `+${Math.round(elapsed)}s`
}
export function ConnectionLog({ entries, title }: Props) {
const scrollRef = useRef<ScrollView | null>(null)
// Why: keep the latest entry visible while logs grow during a slow
// connect. Skip on empty so the title row doesn't jump.
useEffect(() => {
if (entries.length === 0) return
const id = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 16)
return () => clearTimeout(id)
}, [entries.length])
if (entries.length === 0) return null
const baseTs = entries[0]!.ts
return (
<View style={styles.container}>
{title && <Text style={styles.title}>{title}</Text>}
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
</Text>
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
</Text>
)}
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
width: '100%',
maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: spacing.xs
},
scroll: {
maxHeight: 200
},
scrollContent: {
gap: 6
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
timestamp: {
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
color: colors.textMuted,
width: 52,
paddingTop: 1
},
glyph: {
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
width: 12,
textAlign: 'center',
paddingTop: 1
},
rowText: {
flex: 1
},
message: {
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
lineHeight: 16
},
detail: {
fontFamily: typography.monoFamily,
fontSize: 11,
color: colors.textMuted,
lineHeight: 14,
marginTop: 1
}
})
+2 -25
View File
@@ -16,6 +16,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
type Repo = {
@@ -91,34 +92,10 @@ const AGENT_COMMANDS: Record<string, string> = {
// ── Agent icons ─────────────────────────────────────────────────────
// SVG paths sourced from the desktop codebase:
// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx
// Claude & OpenAI: shared in ./AgentIcons.tsx
// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx
// Agents with a faviconDomain use Google's favicon service (same as desktop).
function ClaudeIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fill="#D97757"
fillRule="nonzero"
/>
</Svg>
)
}
function OpenAIIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
fill={colors.textPrimary}
fillRule="evenodd"
/>
</Svg>
)
}
function PiIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800">
@@ -0,0 +1,105 @@
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { router } from 'expo-router'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { CompatVerdict } from '../transport/protocol-compat'
import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version'
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
type Props = {
verdict: Extract<CompatVerdict, { kind: 'blocked' }>
}
export function ProtocolBlockScreen({ verdict }: Props) {
const isMobileTooOld = verdict.reason === 'mobile-too-old'
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop'
const body = isMobileTooOld
? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.`
: `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.`
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{/* Why: only desktop side has a deep-link target today —
App Store ID isn't published yet, so mobile-too-old is text-only. */}
{!isMobileTooOld ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(RELEASES_URL)
}}
>
<Text style={styles.primaryButtonText}>Open GitHub Releases</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
onPress={() => {
// Why: route back to the host list so the user can pair a
// different host instead of getting trapped on this screen.
router.replace('/')
}}
>
<Text style={styles.secondaryButtonText}>Pair a different host</Text>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
justifyContent: 'center',
paddingHorizontal: spacing.lg
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.borderSubtle
},
title: {
fontSize: typography.titleSize,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: spacing.sm
},
body: {
fontSize: typography.bodySize,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.lg
},
primaryButton: {
backgroundColor: colors.textPrimary,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center',
marginBottom: spacing.sm
},
primaryButtonText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.bgBase
},
secondaryButton: {
backgroundColor: colors.bgRaised,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
},
secondaryButtonText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textPrimary
},
pressed: {
opacity: 0.7
}
})
+21 -3
View File
@@ -1,18 +1,36 @@
import { View, StyleSheet } from 'react-native'
import { colors } from '../theme/mobile-theme'
import type { ConnectionState } from '../transport/types'
import type { ConnectionVerdict } from '../transport/connection-health'
const stateColors: Record<ConnectionState, string> = {
connected: colors.statusGreen,
connecting: colors.statusAmber,
handshaking: colors.statusAmber,
reconnecting: colors.statusAmber,
disconnected: colors.statusRed,
disconnected: colors.textMuted,
'auth-failed': colors.statusRed
}
export function StatusDot({ state }: { state: ConnectionState }) {
return <View style={[styles.dot, { backgroundColor: stateColors[state] ?? colors.textMuted }]} />
// Why: when caller passes a verdict, the dot color reflects the verdict's
// severity instead of the raw transport state. This avoids the "amber dot
// next to red 'Can't reach desktop' label" mismatch — the underlying
// transport is still 'reconnecting' (amber) but the user-visible meaning
// has escalated to error (red).
export function StatusDot({
state,
verdict
}: {
state: ConnectionState
verdict?: ConnectionVerdict
}) {
const color =
verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
? colors.statusRed
: verdict?.kind === 'warning'
? colors.statusAmber
: (stateColors[state] ?? colors.textMuted)
return <View style={[styles.dot, { backgroundColor: color }]} />
}
const styles = StyleSheet.create({
+20 -28
View File
@@ -41,23 +41,19 @@ export function TextInputModal({
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<View style={styles.group}>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
</View>
</View>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
<View style={styles.actions}>
<Pressable
@@ -97,16 +93,12 @@ const styles = StyleSheet.create({
color: colors.textMuted,
marginTop: 2
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
inputWrap: {
padding: spacing.md
},
// Why: matches NewWorktreeModal's input — bgRaised on the modal
// background reads as a tappable surface (brighter than the wrapper)
// rather than a recessed pit (darker than the wrapper, which is what
// bgBase looked like inside a bgPanel group).
input: {
backgroundColor: colors.bgBase,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
@@ -127,7 +119,7 @@ const styles = StyleSheet.create({
borderRadius: radii.button
},
submitButton: {
backgroundColor: colors.accentBlue,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button
@@ -144,7 +136,7 @@ const styles = StyleSheet.create({
fontWeight: '500'
},
submitText: {
color: '#fff',
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
@@ -16,22 +16,32 @@ type SubscribeResult = {
subscriptionId: string
}
let permissionGranted: boolean | null = null
export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
return {
granted: status === 'granted',
status,
canAskAgain
}
}
// Why: permissions must be requested before scheduling any local notification.
// Cache the result so we only prompt once per app session.
// Read the OS state every time because users can change it in Settings while
// Orca remains alive in the background.
export async function ensureNotificationPermissions(): Promise<boolean> {
if (permissionGranted !== null) return permissionGranted
const { status: existingStatus } = await Notifications.getPermissionsAsync()
if (existingStatus === 'granted') {
permissionGranted = true
const existing = await getNotificationPermissionState()
if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
permissionGranted = status === 'granted'
return permissionGranted
return status === 'granted'
}
function configureNotificationChannel(): void {
+32
View File
@@ -11,3 +11,35 @@ export function triggerMediumImpact(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
}
}
export function triggerSelection(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Gesture_Start).catch(() => {})
} else {
void Haptics.selectionAsync().catch(() => {})
}
}
export function triggerSuccess(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Confirm).catch(() => {})
} else {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
}
}
export function triggerError(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Reject).catch(() => {})
} else {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
}
}
export function triggerEdgeBump(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Clock_Tick).catch(() => {})
} else {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
}
}
+7 -2
View File
@@ -4,13 +4,18 @@ const PINS_PREFIX = 'orca:pins:'
const PREFS_PREFIX = 'orca:prefs:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
// fires until the user explicitly opts in via Settings → Notifications.
// Apple's review guideline 4.5.4 and HIG both prefer user-initiated
// permission prompts; default-on would fire the prompt the moment the
// desktop sent its first notification, which can read as unsolicited.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(NOTIF_KEY)
if (raw === null) return true
if (raw === null) return false
return raw === 'true'
} catch {
return true
return false
}
}
File diff suppressed because it is too large Load Diff
+476
View File
@@ -0,0 +1,476 @@
// Why: collapses the per-screen WebSocket connection model into a single
// shared RpcClient per host. Implements the design in
// docs/mobile-shared-client-per-host.md.
//
// Lifecycle rules:
// - First request for a host opens its client lazily.
// - Refcount tracks active subscribers; when it drops to zero we schedule
// a 30-second idle close timer. If a new subscriber arrives within that
// window we cancel and reuse the same client.
// - removeHost() forces an immediate close so re-pairing gets a fresh
// transport.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number
unsubState: () => void
}
type ContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getReconnectAttempt: (hostId: string) => number
// Why: timestamp (ms epoch) of the last successful 'connected' state
// transition for this host, or null if never connected this session.
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
getLastConnectedAt: (hostId: string) => number | null
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
// Why: lets the home screen feed already-loaded HostProfiles in so we
// don't pay loadHosts() latency twice (once in the focus-effect, again
// inside openEntry).
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<ContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
// entire tree on every connection state change. State propagation goes
// through per-host listener Sets instead.
const storeRef = useRef<Map<string, StoreEntry>>(new Map())
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
// Pending opens (avoid two acquire() callers in the same render racing the
// host lookup). Keyed by hostId, value is a sentinel resolved when the
// entry materialises.
const pendingOpensRef = useRef<Map<string, Promise<void>>>(new Map())
// Why: a fast-path cache of already-loaded HostProfiles. Screens that
// have run loadHosts() can call primeHosts() to populate this and skip
// the second loadHosts() inside openEntry. Without this we'd serialize
// two Keychain passes on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
function notifyHostState(hostId: string, state: ConnectionState) {
const set = stateListenersRef.current.get(hostId)
if (!set) return
for (const listener of set) listener(state)
}
function notifyAllHosts() {
for (const listener of allHostsListenersRef.current) listener()
}
const closeEntry = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
}, [])
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
const existing = pendingOpensRef.current.get(hostId)
if (existing) {
await existing
return storeRef.current.get(hostId) ?? null
}
let resolve: () => void = () => {}
const promise = new Promise<void>((res) => {
resolve = res
})
pendingOpensRef.current.set(hostId, promise)
try {
// Why: prefer the primed cache (populated by primeHosts when the
// screen already ran loadHosts) so we don't serialize a second
// Keychain pass behind the first one on cold start.
let host = primedHostsRef.current.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((h) => h.id === hostId)
} catch {
// Why: a Keychain failure on cold start (rare but observed —
// happens when iOS Keychain is mid-unlock or Android Keystore
// races the JS bridge). Surface it as 'disconnected' so the
// home card flips off the perma-spinner and the user can hit
// Reconnect from the action sheet to retry.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) return null
}
// Re-check after any await — another acquire() may have completed.
const after = storeRef.current.get(hostId)
if (after) return after
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64)
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
// doesn't sit on a stale 'connecting' label forever.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
const unsubState = client.onStateChange((state) => {
const cur = storeRef.current.get(hostId)
if (!cur) return
cur.state = state
notifyHostState(hostId, state)
})
const entry: StoreEntry = {
client,
state: client.getState(),
refCount: 0,
unsubState
}
storeRef.current.set(hostId, entry)
notifyHostState(hostId, entry.state)
notifyAllHosts()
return entry
} finally {
pendingOpensRef.current.delete(hostId)
resolve()
}
}, [])
// Why: `acquire` is the synchronous get-or-open. If the entry already
// exists, return its client immediately and bump the refcount. If not,
// kick off an async open (the consumer will subscribe via
// `subscribeHostState` and re-read once 'connecting' fires). Optionally
// accepts the HostProfile so the caller can avoid an extra loadHosts()
// pass inside openEntry.
const acquire = useCallback(
(hostId: string, host?: HostProfile): RpcClient | null => {
if (host) primedHostsRef.current.set(hostId, host)
const existing = storeRef.current.get(hostId)
if (existing) {
existing.refCount += 1
return existing.client
}
// Trigger async open. The acquire-side will return null this tick and
// try again once the state listener fires; consumers are expected to
// call acquire() inside an effect that re-runs on state changes.
void openEntry(hostId).then((entry) => {
if (!entry) return
entry.refCount += 1
})
return null
},
[openEntry]
)
const primeHosts = useCallback((hosts: HostProfile[]) => {
for (const host of hosts) primedHostsRef.current.set(host.id, host)
}, [])
// Why: refcount dropping to 0 no longer triggers an idle-close. The
// app deliberately keeps live WebSockets open while the app itself is
// foregrounded — closing on transient navigation gaps was producing
// false 'disconnected' flashes when the user navigated home → host →
// back to home faster than React could re-acquire on the home side.
// Connections still close on: explicit user Disconnect, host removal,
// app backgrounding (OS-level socket suspension), and provider
// unmount (app shutdown).
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
entry.refCount = Math.max(0, entry.refCount - 1)
}, [])
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: preserve refcount across the swap. If the user reaches
// forceReconnect via the Disconnect → Reconnect path, the entry
// was already closed and refCount=0; fall back to active listener
// count as a proxy for "screens still watching this host."
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
}
const fresh = await openEntry(hostId)
if (fresh) fresh.refCount = savedRefCount
},
[openEntry]
)
const getState = useCallback((hostId: string): ConnectionState => {
return storeRef.current.get(hostId)?.state ?? 'disconnected'
}, [])
const getReconnectAttempt = useCallback((hostId: string): number => {
return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0
}, [])
const getLastConnectedAt = useCallback((hostId: string): number | null => {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
if (!set) {
set = new Set()
stateListenersRef.current.set(hostId, set)
}
set.add(listener)
return () => {
const s = stateListenersRef.current.get(hostId)
if (!s) return
s.delete(listener)
if (s.size === 0) stateListenersRef.current.delete(hostId)
}
},
[]
)
const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => {
const out: Array<{ hostId: string; client: RpcClient }> = []
for (const [hostId, entry] of storeRef.current) {
out.push({ hostId, client: entry.client })
}
return out
}, [])
const subscribeAllHosts = useCallback((listener: () => void) => {
allHostsListenersRef.current.add(listener)
return () => {
allHostsListenersRef.current.delete(listener)
}
}, [])
// Close all clients on provider unmount (app shutdown).
// Why: deps must be empty so this cleanup ONLY runs on real unmount.
// Hot-reload re-evaluates this module, which makes closeEntry a new
// function identity. With [closeEntry] as deps, every Fast Refresh
// would tear down all open WebSockets, leaving screens holding closed
// clients and the user staring at a 'Reconnecting…' card. Reading
// storeRef.current and the locally-scoped closeEntry inside the
// cleanup is safe — the ref is stable across renders, and the
// function captured here will be the one defined in the same
// closure as this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const store = storeRef.current
return () => {
for (const [hostId] of store) closeEntry(hostId)
}
}, [])
const value = useMemo<ContextValue>(
() => ({
acquire,
release,
forceReconnect,
closeHost: closeEntry,
getState,
getReconnectAttempt,
getLastConnectedAt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
primeHosts
}),
[
acquire,
release,
forceReconnect,
closeEntry,
getState,
getReconnectAttempt,
getLastConnectedAt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
primeHosts
]
)
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
function useCtx(): ContextValue {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useHostClient must be used inside <RpcClientProvider>')
return ctx
}
// Why: the primary hook for screens. Acquires the shared client for a
// hostId on mount and releases on unmount. Re-renders when the host's
// connection state changes.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useCtx()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
useEffect(() => {
if (!hostId) {
clientRef.current = null
setState('disconnected')
return
}
let cancelled = false
// Subscribe before acquire so any state change during open is captured.
const unsub = ctx.subscribeHostState(hostId, (next) => {
if (cancelled) return
setState(next)
// Why: if the client was null at first acquire (async open), the
// first state change ('connecting'/'handshaking'/'connected') is our
// signal to re-read.
if (clientRef.current == null) {
const all = ctx.getAllClients()
const found = all.find((entry) => entry.hostId === hostId)
if (found) {
clientRef.current = found.client
force((n) => n + 1)
}
}
})
const initial = ctx.acquire(hostId)
if (initial) {
clientRef.current = initial
setState(ctx.getState(hostId))
}
return () => {
cancelled = true
unsub()
ctx.release(hostId)
clientRef.current = null
}
}, [ctx, hostId])
return { client: clientRef.current, state }
}
// Why: home screen renders all paired hosts at once. Acquires each on
// mount, releases on unmount. The provider's refcounting ensures we
// don't double-open if a host-detail screen is also open.
export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
}> {
const ctx = useCtx()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
useEffect(() => {
if (hostIds.length === 0) return
for (const id of hostIds) ctx.acquire(id)
const unsubs: Array<() => void> = []
for (const id of hostIds) {
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
}
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
return () => {
for (const u of unsubs) u()
for (const id of hostIds) ctx.release(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key])
return useMemo(() => {
const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({ hostId: id, client: all.client, state: ctx.getState(id) })
}
}
return out
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, tick])
}
// Why: removeHost() in host-store.ts must close the live client, but
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
export function useCloseHost(): (hostId: string) => void {
const ctx = useCtx()
return ctx.closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useCtx()
return ctx.forceReconnect
}
// Why: lets the home screen feed already-loaded HostProfiles in so the
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useCtx()
return ctx.primeHosts
}
// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more
// alarming "Can't connect" once the rpc-client has cycled enough times to
// indicate something's actually wrong (wrong port, server down, network
// loss). Reads through the context so it stays in sync with the live
// rpc-client instance even after forceReconnect swaps the underlying
// client.
export function useReconnectAttempt(hostId: string | undefined): number {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) return
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}
// Why: timestamp of last successful connect for this host, or null if
// the client has never connected. Combined with reconnectAttempt this
// distinguishes "transient blip" (recently connected) from "host
// appears unreachable" (never connected, or hasn't connected in N
// seconds despite many retry attempts).
export function useLastConnectedAt(hostId: string | undefined): number | null {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) return
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getLastConnectedAt(hostId) : null
}
+88
View File
@@ -0,0 +1,88 @@
import type { ConnectionState } from './types'
// Why: thresholds for escalating connection UX from neutral
// "Reconnecting…" to alarming "host appears unreachable, re-pair?".
//
// - WARNING_ATTEMPTS: 3 → label flips to "Can't connect" (existing
// behavior). Calibrated to absorb a normal laptop wake / brief
// network blip without alarming the user.
// - UNREACHABLE_ATTEMPTS: 12 → with the tiered 0.5s→60s backoff this
// is ≈ 6 minutes of continuous failure (the last four attempts all
// reuse the 60s cap). Combined with the never-connected /
// stale-since-last-connect heuristic below, this is the trigger to
// surface a "re-pair?" affordance. MUST stay aligned with
// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS.
// - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this
// session but haven't been for ≥ 1 minute despite the retry loop
// spinning, treat the same as never-connected. Catches the case
// where the desktop's IP changed mid-session.
export const WARNING_ATTEMPTS = 3
export const UNREACHABLE_ATTEMPTS = 12
export const STALE_SINCE_LAST_CONNECT_MS = 60_000
export type ConnectionVerdict =
| { kind: 'normal'; label: string }
| { kind: 'warning'; label: string } // "Can't connect"
| { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' }
| { kind: 'auth-failed'; label: string }
// Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have
// to recompute "are we currently stale" against now() each render.
// Centralized so home + host-detail show identical verdicts.
export function classifyConnection(args: {
state: ConnectionState
reconnectAttempts: number
lastConnectedAt: number | null
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
const now = args.nowMs ?? Date.now()
if (state === 'auth-failed') {
return { kind: 'auth-failed', label: 'Auth failed' }
}
// Connected / connecting / handshaking are normal.
if (state === 'connected') return { kind: 'normal', label: 'Connected' }
if (state === 'connecting' || state === 'handshaking') {
return { kind: 'normal', label: 'Connecting…' }
}
if (state === 'disconnected') {
return { kind: 'normal', label: 'Disconnected' }
}
// state === 'reconnecting' from here.
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'never-connected'
}
}
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'stale'
}
}
}
if (reconnectAttempts >= WARNING_ATTEMPTS) {
return { kind: 'warning', label: "Can't connect" }
}
return { kind: 'normal', label: 'Reconnecting…' }
}
// Why: the message under the banner explains what likely happened so the
// user understands why we're suggesting Re-pair. Tuned to be specific
// about IP/port without being technical (we don't want to leak
// "ws://192.168.x.y:port" unless someone is debugging).
export function unreachableHint(reason: 'never-connected' | 'stale'): string {
return reason === 'never-connected'
? "Can't reach this Orca desktop. Its network address may have changed since pairing — try re-pairing from the desktop's Settings → Mobile screen."
: 'Lost contact with the Orca desktop. If your network changed (different Wi-Fi, IP renewed, or desktop restarted), try re-pairing.'
}
+128 -13
View File
@@ -1,16 +1,110 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { HostProfileSchema, type HostProfile } from './types'
import * as SecureStore from 'expo-secure-store'
import {
HostProfileSchema,
StoredHostProfileSchema,
type HostProfile,
type StoredHostProfile
} from './types'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
// Use dots as the separator so the key shape stays readable while
// satisfying the validator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
// different physical device. Reads/writes are silent (no biometric
// prompt) since we don't request access control flags.
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
// triggers connection-churn cycles in the home-screen useEffect. Cache
// per-hostId in memory; invalidate only on save/remove. The cache lives
// for the JS-runtime lifetime, which matches AsyncStorage semantics
// (cleared on app uninstall, persisted across foreground/background).
const tokenCache = new Map<string, string>()
let inflightLoad: Promise<HostProfile[]> | null = null
export async function loadHosts(): Promise<HostProfile[]> {
// Why: deduplicate concurrent loadHosts() calls so multiple screens
// mounting simultaneously share one Keychain read pass.
if (inflightLoad) return inflightLoad
inflightLoad = doLoadHosts().finally(() => {
inflightLoad = null
})
return inflightLoad
}
async function doLoadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const out: HostProfile[] = []
for (const item of parsed) {
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
// Drop them silently — the three pre-launch users will re-pair on
// first run rather than carry a migration shim through the auth
// path.
if (item && typeof item === 'object' && 'deviceToken' in item) {
continue
}
const stored = StoredHostProfileSchema.safeParse(item)
if (!stored.success) continue
let token = tokenCache.get(stored.data.id)
if (!token) {
let fetched: string | null
try {
fetched = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
} catch {
// Why: a transient Keychain failure for one entry (e.g.
// errSecInteractionNotAllowed while the device is briefly locked,
// or a single corrupt record) must not blank the entire host list.
// Skip just this host — it'll reappear on the next load.
continue
}
if (!fetched) {
// Why: orphaned metadata with no matching keychain entry — most
// likely a stale record from a development install. Skip it
// rather than surface a half-broken host.
continue
}
token = fetched
tokenCache.set(stored.data.id, token)
}
out.push({ ...stored.data, deviceToken: token })
}
return out
}
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.flatMap((item) => {
const result = HostProfileSchema.safeParse(item)
// Why: same drop-old-records rule as loadHosts; keeps internal
// mutators from re-persisting pre-v0.0.3 entries.
if (item && typeof item === 'object' && 'deviceToken' in item) return []
const result = StoredHostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
@@ -18,25 +112,46 @@ export async function loadHosts(): Promise<HostProfile[]> {
}
}
export async function saveHost(host: HostProfile): Promise<void> {
const hosts = await loadHosts()
const index = hosts.findIndex((h) => h.id === host.id)
if (index >= 0) {
hosts[index] = host
} else {
hosts.push(host)
function toStored(host: HostProfile): StoredHostProfile {
return {
id: host.id,
name: host.name,
endpoint: host.endpoint,
publicKeyB64: host.publicKeyB64,
lastConnected: host.lastConnected
}
}
export async function saveHost(host: HostProfile): Promise<void> {
const validated = HostProfileSchema.parse(host)
const hosts = await loadStoredHosts()
const stored = toStored(validated)
const index = hosts.findIndex((h) => h.id === stored.id)
if (index >= 0) {
hosts[index] = stored
} else {
hosts.push(stored)
}
// Why: write metadata BEFORE the keychain token so a crash between the two
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
// up) rather than an orphaned keychain token with no metadata pointer —
// the latter would persist forever since removeHost only deletes by hostId
// from current metadata.
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS)
tokenCache.set(stored.id, validated.deviceToken)
}
export async function removeHost(hostId: string): Promise<void> {
const hosts = await loadHosts()
const hosts = await loadStoredHosts()
const filtered = hosts.filter((h) => h.id !== hostId)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
tokenCache.delete(hostId)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {
const hosts = await loadHosts()
const hosts = await loadStoredHosts()
const host = hosts.find((h) => h.id === hostId)
if (host) {
host.name = newName
@@ -45,7 +160,7 @@ export async function renameHost(hostId: string, newName: string): Promise<void>
}
export async function getNextHostName(): Promise<string> {
const hosts = await loadHosts()
const hosts = await loadStoredHosts()
const existingNumbers = hosts
.map((h) => {
const match = h.name.match(/^Host (\d+)$/)
@@ -57,7 +172,7 @@ export async function getNextHostName(): Promise<string> {
}
export async function updateLastConnected(hostId: string): Promise<void> {
const hosts = await loadHosts()
const hosts = await loadStoredHosts()
const host = hosts.find((h) => h.id === hostId)
if (host) {
host.lastConnected = Date.now()
+28 -6
View File
@@ -1,16 +1,38 @@
import { PairingOfferSchema, type PairingOffer } from './types'
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's
// Buffer. Keep the parsing semantics in sync — when one changes, update
// the other.
export function decodePairingUrl(url: string): PairingOffer | null {
try {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
const base64url = url.slice(hashIndex + 1)
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(base64)
const parsed = JSON.parse(json)
return PairingOfferSchema.parse(parsed)
return decodePairingBase64(url.slice(hashIndex + 1))
} catch {
return null
}
}
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
// string so the paste-pair flow can take whichever the user actually
// copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
const trimmed = input.trim()
if (!trimmed) return null
try {
if (trimmed.startsWith('orca://pair')) {
return decodePairingUrl(trimmed)
}
return decodePairingBase64(trimmed)
} catch {
return null
}
}
function decodePairingBase64(base64url: string): PairingOffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(base64)
return PairingOfferSchema.parse(JSON.parse(json))
}
+42
View File
@@ -0,0 +1,42 @@
// Why: this file mirrors src/shared/protocol-compat.ts (which is
// covered by CI vitest). Metro can't resolve out of mobile/, so the
// pure function is duplicated here. Keep the two files in sync — when
// you change the evaluator's logic, update both. The src/shared/ copy
// is the tested canonical version.
import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version'
export type CompatVerdict =
| { kind: 'ok' }
| {
kind: 'blocked'
reason: 'mobile-too-old' | 'desktop-too-old'
desktopVersion: number
requiredMobileVersion?: number
requiredDesktopVersion?: number
}
export function evaluateCompat(input: {
desktopProtocolVersion: number | undefined
desktopMinCompatibleMobileVersion: number | undefined
}): CompatVerdict {
const desktopVersion = input.desktopProtocolVersion ?? 0
const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0
if (MOBILE_PROTOCOL_VERSION < requiredMobile) {
return {
kind: 'blocked',
reason: 'mobile-too-old',
desktopVersion,
requiredMobileVersion: requiredMobile
}
}
if (desktopVersion < MIN_COMPATIBLE_DESKTOP_VERSION) {
return {
kind: 'blocked',
reason: 'desktop-too-old',
desktopVersion,
requiredDesktopVersion: MIN_COMPATIBLE_DESKTOP_VERSION
}
}
return { kind: 'ok' }
}
+20
View File
@@ -0,0 +1,20 @@
// Why: declares the mobile's pairing protocol version and the minimum
// desktop version it can talk to. Duplicates the desktop's
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
// outside `mobile/`. Manual sync is acceptable — these constants are
// expected to bump less than once a quarter.
//
// Bump MOBILE_PROTOCOL_VERSION when:
// - You change the meaning of an RPC mobile sends.
// - You stop relying on a desktop-side feature in a way old desktops
// would notice.
// Do NOT bump for:
// - Adding new optional fields to outbound requests.
// - Reading new optional fields on incoming responses.
//
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
// desktop feature added at a specific desktop protocol version. This
// triggers a hard-block screen for users paired to older desktops.
export const MOBILE_PROTOCOL_VERSION = 1
export const MIN_COMPATIBLE_DESKTOP_VERSION = 0
+111
View File
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => raw.replace(/^encrypted:/, '')
}))
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSING = MockWebSocket.CLOSING
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: (() => void) | null = null
emitCloseOnClose = true
sent: string[] = []
close = vi.fn(() => {
if (this.readyState === MockWebSocket.CLOSED) return
this.readyState = MockWebSocket.CLOSED
if (this.emitCloseOnClose) {
this.onclose?.()
}
})
constructor(readonly endpoint: string) {
mockSockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
receive(payload: string): void {
this.onmessage?.({ data: payload })
}
}
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
describe('mobile rpc-client connection timeout', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
vi.useRealTimers()
globalThis.WebSocket = originalWebSocket
})
it('closes a socket that never opens so reconnect can run', () => {
const states: string[] = []
const client = connect('ws://desktop.invalid', 'token', 'server-key', (state) => {
states.push(state)
})
expect(client.getState()).toBe('connecting')
expect(mockSockets).toHaveLength(1)
mockSockets[0]!.emitCloseOnClose = false
vi.advanceTimersByTime(12_000)
expect(mockSockets[0]!.close).toHaveBeenCalledTimes(1)
expect(client.getState()).toBe('reconnecting')
expect(states).toContain('reconnecting')
client.close()
})
it('clears the open timeout once the socket opens and authenticates', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
expect(client.getState()).toBe('connected')
vi.advanceTimersByTime(12_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
client.close()
})
})
+446 -18
View File
@@ -1,4 +1,10 @@
import type { RpcResponse, RpcSuccess, ConnectionState } from './types'
import type {
RpcResponse,
RpcSuccess,
ConnectionState,
ConnectionLogLevel,
ConnectionLogSink
} from './types'
import {
generateKeyPair,
deriveSharedKey,
@@ -25,27 +31,111 @@ export type RpcClient = {
sendRequest: (method: string, params?: unknown) => Promise<RpcResponse>
subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void
getState: () => ConnectionState
// Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross
// a threshold. 0 means never failed; counter is reset on successful open.
getReconnectAttempt: () => number
// Why: timestamp (ms epoch) of the last time we reached 'connected'.
// null = never connected since the client was created. Used by the UI
// to distinguish "host moved/never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
close: () => void
}
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
// Why: tiered backoff. The first four entries (500ms→4s) keep
// auto-recovery snappy for the common case — a brief Wi-Fi blip,
// laptop wake, or AP-isolation cycle. Beyond that we slow down
// (8s→60s) so a phone whose desktop is genuinely unreachable doesn't
// burn a TCP SYN every 4s indefinitely while still healing on its
// own when the network recovers. With 12 total attempts, the last
// four reuse the 60s cap (Math.min(idx, length-1)), so total elapsed
// time across all 12 attempts is ≈ 6 minutes before the give-up cap
// fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s).
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
// Why: cap auto-retry once we're clearly unreachable for a long time.
// With the tiered backoff above this is ≈ 6 minutes of continuous
// failure before we stop and surface the re-pair banner. The longer
// runway tolerates flaky AP-isolation routers and laptop sleep cycles
// that briefly drop the LAN path. MUST stay aligned with
// connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable"
// verdict matches the moment the loop actually pauses — if these
// drift the user sees "Reconnecting…" while the loop is silently
// parked.
const GIVE_UP_AFTER_ATTEMPTS = 12
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
// Why: RN's WebSocket implementation may not expose static readyState
// constants, but the protocol value for CONNECTING is stable across runtimes.
const WEBSOCKET_CONNECTING_STATE = 0
// Why: app-level liveness probe. The server runs its own ping/pong sweep
// at 15s, but RN's WebSocket runtime auto-pongs at the native layer
// without surfacing anything to JS — so the mobile side can't *see* that
// the server thinks the link is fine. To detect a half-open socket from
// the mobile direction (e.g. server crashed, phone moved between wifi
// and cellular without TCP RST) we periodically round-trip a tiny RPC.
// If two consecutive probes time out we force-close the WS, which fires
// the existing reconnect path. 20s cadence + the 30s request timeout =
// worst-case ~50s before mobile decides the link is dead and kicks
// reconnect, which is still inside the user's perceived "responsive"
// window and well below iOS's typical background-disconnect window.
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
export type ConnectOptions = {
onStateChange?: (state: ConnectionState) => void
// Fires for every observable lifecycle event so the UI can render a
// detailed connection log. Useful when 'Connecting…' hangs forever
// (e.g. broken Tailscale route) and you need to see *where* it's stuck.
onLog?: ConnectionLogSink
}
export function connect(
endpoint: string,
deviceToken: string,
serverPublicKeyB64: string,
onStateChange?: (state: ConnectionState) => void
optionsOrLegacy?: ConnectOptions | ((state: ConnectionState) => void)
): RpcClient {
// Why: keep backward-compat with callers that pass a bare onStateChange fn.
const options: ConnectOptions =
typeof optionsOrLegacy === 'function'
? { onStateChange: optionsOrLegacy }
: (optionsOrLegacy ?? {})
const onStateChange = options.onStateChange
const onLog = options.onLog
let logCounter = 0
function emitLog(level: ConnectionLogLevel, message: string, detail?: string) {
if (!onLog) return
onLog({
id: `log-${++logCounter}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail
})
}
let ws: WebSocket | null = null
let state: ConnectionState = 'disconnected'
let requestCounter = 0
let reconnectAttempt = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let connectTimer: ReturnType<typeof setTimeout> | null = null
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
let intentionallyClosed = false
let lastConnectedAt: number | null = null
// Why: diagnostic — when the rpc-client gets stuck in a state where every
// openConnection fails with code 1006 and only a force-quit recovers, we
// need to see whether (a) the new attempts even differ from the old ones,
// (b) anything is happening at the OS / RN-bridge layer between attempts,
// and (c) what the timing pattern is (instant 1006 = port closed / route
// dead, slow 1006 = packet drop / timeout). These three timestamps + the
// ws-construction counter are the cheapest visibility into RN/OkHttp
// process-state poisoning hypotheses.
let lastInboundAt: number | null = null
let lastWsClosedAt: number | null = null
let wsConstructionCounter = 0
let currentWsOpenedAt: number | null = null
// Why: fresh ephemeral keypair per connection provides forward secrecy.
// The shared key is derived from our ephemeral secret + server's static public key.
@@ -61,10 +151,26 @@ export function connect(
stateListeners.add(onStateChange)
}
// Diagnostic: tracks how long we've been in the current state. Useful
// for spotting "stuck in connecting" or "stuck in reconnecting" cases
// in the logs.
let stateEnteredAt = Date.now()
function setState(next: ConnectionState) {
if (state === next) return
const prev = state
const dwelt = Date.now() - stateEnteredAt
state = next
stateEnteredAt = Date.now()
console.log('[net] state', {
from: prev,
to: next,
dweltMs: dwelt,
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint)
})
if (next === 'connected') {
lastConnectedAt = Date.now()
for (const w of connectWaiters.splice(0)) w.resolve()
} else if (next === 'disconnected' || next === 'auth-failed') {
const reason =
@@ -76,6 +182,17 @@ export function connect(
}
}
// Why: don't dump device tokens / full URLs into log scrolls; truncate to
// the host:port so reconnect lifecycles are still readable.
function redactedEndpoint(ep: string): string {
try {
const m = ep.match(/^wss?:\/\/([^/]+)/i)
return m ? m[1] : 'unknown'
} catch {
return 'unknown'
}
}
function waitForConnected(): Promise<void> {
if (state === 'connected') return Promise.resolve()
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
@@ -91,14 +208,62 @@ export function connect(
function openConnection() {
if (intentionallyClosed) return
const now = Date.now()
wsConstructionCounter++
console.log('[net] openConnection', {
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint),
// Why: process-poisoning diagnostic. If wsCount is high (e.g. >50)
// and every recent open fails with 1006, suspect RN/OkHttp internal
// pool corruption that only force-quit clears. Compare msSinceLast*
// values to the failure cadence: instant repeated fails with no
// inbound traffic between them = process-state stuck.
wsCount: wsConstructionCounter,
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null
})
setState('connecting')
sharedKey = null
currentWsOpenedAt = now
emitLog(
'info',
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
endpoint
)
ws = new WebSocket(endpoint)
const openingWs = ws
// Why: React Native can leave TCP/WebSocket opens pending indefinitely on
// flaky network handoffs. Force the existing onclose reconnect path if
// onopen never arrives, instead of leaving the UI stuck at "Connecting...".
connectTimer = setTimeout(() => {
connectTimer = null
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
console.log('[net] connect-timeout fired (onopen never arrived)', {
attempt: reconnectAttempt,
timeoutMs: CONNECT_TIMEOUT_MS
})
emitLog(
'error',
'WebSocket connect timeout',
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
)
openingWs.close()
if (ws === openingWs) {
handleSocketClosed(openingWs, { timedOut: true })
}
}
}, CONNECT_TIMEOUT_MS)
ws.onopen = () => {
console.log('[net] ws.onopen', { attempt: reconnectAttempt })
clearConnectTimer()
reconnectAttempt = 0
setState('handshaking')
emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
// Why: generate a fresh ephemeral keypair for each connection.
// This provides forward secrecy — compromising one session's key
@@ -109,16 +274,28 @@ export function connect(
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
})
ws?.send(hello)
emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready')
sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey)
handshakeTimer = setTimeout(() => {
handshakeTimer = null
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
timeoutMs: HANDSHAKE_TIMEOUT_MS
})
emitLog(
'error',
'Handshake timeout',
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
)
ws?.close()
}, HANDSHAKE_TIMEOUT_MS)
}
ws.onmessage = (event) => {
// Why: track last-inbound for the openConnection diagnostic. Server
// pongs and stream events both bump this — anything from the wire.
lastInboundAt = Date.now()
const raw = typeof event.data === 'string' ? event.data : String(event.data)
// Why: during handshaking, e2ee_ready is plaintext because it precedes
@@ -127,6 +304,7 @@ export function connect(
try {
const msg = JSON.parse(raw)
if (msg.type === 'e2ee_ready') {
emitLog('success', 'Received e2ee_ready', 'Sending device token')
sendEncrypted({ type: 'e2ee_auth', deviceToken })
return
}
@@ -150,11 +328,22 @@ export function connect(
clearTimeout(handshakeTimer)
handshakeTimer = null
}
console.log('[net] e2ee_authenticated — connected', {
streamCount: streamListeners.size
})
setState('connected')
emitLog('success', 'Authenticated', 'Channel ready for RPC')
startActivityProbe()
for (const [id, stream] of streamListeners) {
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
}
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
emitLog(
'error',
'Authentication rejected',
typeof msg.error?.message === 'string' ? msg.error.message : 'Unauthorized'
)
intentionallyClosed = true
ws?.close()
ws = null
@@ -232,37 +421,231 @@ export function connect(
}
}
ws.onclose = () => {
ws = null
sharedKey = null
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
ws.onclose = (event) => {
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
const closeAt = Date.now()
// Why: time-since-construct distinguishes failure modes. Instant
// close (<300ms) = TCP RST / port closed / route unreachable / RN
// synchronous reject. Mid (300ms–3s) = DNS/connect attempt + reset.
// Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an
// entire reconnect burst is all instant, the problem is local
// process state or routing, not packet loss.
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
// Why: inline the diagnostic dump. Earlier hot-reload tripped
// `Property 'enumKeys' doesn't exist` because a stale closure
// captured a half-loaded module. Inlining keeps the handler's
// behavior fully decided at construction time.
let closeEventKeys: string[] = []
let closeEventStr = ''
try {
closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
closeEventKeys = []
}
if (intentionallyClosed) {
setState('disconnected')
rejectAllPending('Connection closed')
return
try {
const seen = new WeakSet<object>()
closeEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[circular]'
seen.add(v as object)
}
if (typeof v === 'function') return '[fn]'
return v
},
0
).slice(0, 500)
} catch {
closeEventStr = '[unstringifiable]'
}
rejectAllPending('Connection interrupted')
setState('reconnecting')
scheduleReconnect()
console.log('[net] ws.onclose', {
code: e?.code,
reason: e?.reason,
wasClean: e?.wasClean,
state,
attempt: reconnectAttempt,
intentionallyClosed,
endpoint: redactedEndpoint(endpoint),
constructToCloseMs,
aliveMs,
inboundIdleMs,
eventKeys: closeEventKeys,
eventStr: closeEventStr
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
handleSocketClosed(openingWs)
}
ws.onerror = () => {
// onclose will fire after this
ws.onerror = (event) => {
// Why: RN surfaces network errors here (DNS failure, TCP RST, etc).
// onclose fires right after, but logging the error message gives us
// the original cause that the close code alone can hide.
const e = event as { message?: string } | undefined
// Why: inlined defensively — see ws.onclose comment.
let errEventKeys: string[] = []
let errEventStr = ''
try {
errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
errEventKeys = []
}
try {
const seen = new WeakSet<object>()
errEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[circular]'
seen.add(v as object)
}
if (typeof v === 'function') return '[fn]'
return v
},
0
).slice(0, 500)
} catch {
errEventStr = '[unstringifiable]'
}
console.log('[net] ws.onerror', {
message: e?.message,
state,
attempt: reconnectAttempt,
eventKeys: errEventKeys,
eventStr: errEventStr
})
}
}
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
if (ws !== closedWs) {
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
state,
attempt: reconnectAttempt
})
return
}
clearConnectTimer()
ws = null
sharedKey = null
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
}
stopActivityProbe()
if (intentionallyClosed) {
console.log('[net] handleSocketClosed — intentional close')
setState('disconnected')
rejectAllPending('Connection closed')
return
}
console.log('[net] handleSocketClosed → reconnect', {
timedOut: !!opts.timedOut,
pendingCount: pending.size,
streamCount: streamListeners.size,
attempt: reconnectAttempt
})
emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect')
rejectAllPending('Connection interrupted')
setState('reconnecting')
scheduleReconnect()
}
function scheduleReconnect() {
// Why: spinning reconnect forever drains battery and floods logs
// when the host is genuinely unreachable (wrong IP, port closed,
// host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a
// "Can't reach desktop, re-pair?" banner at this point and the
// user can tap Retry (forceReconnect creates a fresh client,
// resetting the counter) or Re-pair. Without an explicit cap the
// worst-case is a phone left on the home screen burning a socket
// open every 4s indefinitely.
if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
console.log('[net] reconnect-paused', {
attempt: reconnectAttempt,
reason: 'give-up-cap',
endpoint: redactedEndpoint(endpoint)
})
return
}
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!
reconnectAttempt++
console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt })
emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
openConnection()
}, delay)
}
function clearConnectTimer() {
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
}
// Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment
// at the top of the file. Fires while the channel is in 'connected'
// state, sends a tiny status.get, and force-closes the WS if the probe
// fails (which the existing onclose path then turns into a reconnect).
function startActivityProbe() {
stopActivityProbe()
activityProbeTimer = setInterval(() => {
// Why: only probe while the channel is actually in 'connected'. The
// sendRequest path itself waits for connected, but a probe scheduled
// during a reconnect would just stack up timeouts and confuse logs.
if (state !== 'connected' || !ws) return
const probeWs = ws
// Why: short timeout (8s) — server's heartbeat is 15s, so if we
// don't see *anything* back within 8s the link is almost certainly
// half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
// user wait nearly a minute before reconnect kicks in.
const id = nextId()
const probeStart = Date.now()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
pending.delete(id)
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
waitedMs: Date.now() - probeStart,
state
})
// Why: only force-close if this is still the same socket the
// probe was sent on; a normal close that already swapped `ws`
// shouldn't trigger a redundant terminate.
if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) {
probeWs.close()
}
}, 8_000)
pending.set(id, {
resolve: () => {
if (timedOut) return
clearTimeout(timeout)
},
reject: () => {
if (timedOut) return
clearTimeout(timeout)
}
})
if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
clearTimeout(timeout)
pending.delete(id)
}
}, ACTIVITY_PROBE_INTERVAL_MS)
}
function stopActivityProbe() {
if (activityProbeTimer) {
clearInterval(activityProbeTimer)
activityProbeTimer = null
}
}
function rejectAllPending(reason: string) {
const error = new Error(reason)
for (const [id, req] of pending) {
@@ -276,6 +659,23 @@ export function connect(
ws.send(encrypt(JSON.stringify(request), sharedKey))
return true
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: !!ws,
readyState: ws?.readyState,
hasKey: !!sharedKey,
state
})
// Why: if the state machine still thinks we're connected but the
// underlying WebSocket has flipped to CLOSING/CLOSED without onclose
// having fired (RN's WebSocket sometimes drops the event, or the
// server half-closed the stream), force a reconnect. Without this
// every send silently fails forever and the user sees a frozen UI.
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: ws.readyState
})
handleSocketClosed(ws, { timedOut: false })
}
return false
}
@@ -283,12 +683,25 @@ export function connect(
return {
async sendRequest(method: string, params?: unknown): Promise<RpcResponse> {
const waitStart = Date.now()
const wasConnected = state === 'connected'
await waitForConnected()
if (!wasConnected) {
console.log('[net] sendRequest waited for connect', {
method,
waitedMs: Date.now() - waitStart
})
}
return new Promise((resolve, reject) => {
const id = nextId()
const timeout = setTimeout(() => {
pending.delete(id)
console.log('[net] sendRequest TIMEOUT', {
method,
timeoutMs: REQUEST_TIMEOUT_MS,
state
})
reject(new Error(`Request timed out: ${method}`))
}, REQUEST_TIMEOUT_MS)
@@ -317,6 +730,11 @@ export function connect(
if (state === 'connected') {
sendEncrypted({ id, deviceToken, method, params })
} else {
// Stream is registered but the actual outbound subscribe will be
// sent (or re-sent) when the channel reaches 'connected'. Useful
// when terminals don't load — confirms the request is queued.
console.log('[net] subscribe queued — waiting for connected', { method, state })
}
return () => {
@@ -360,6 +778,14 @@ export function connect(
return state
},
getReconnectAttempt(): number {
return reconnectAttempt
},
getLastConnectedAt(): number | null {
return lastConnectedAt
},
onStateChange(listener: (state: ConnectionState) => void): () => void {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
@@ -371,10 +797,12 @@ export function connect(
clearTimeout(reconnectTimer)
reconnectTimer = null
}
clearConnectTimer()
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
}
stopActivityProbe()
if (ws) {
ws.close()
ws = null
+27
View File
@@ -35,6 +35,20 @@ export const PairingOfferSchema = z.object({
export type PairingOffer = z.infer<typeof PairingOfferSchema>
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
export type ConnectionLogEntry = {
id: string
ts: number
level: ConnectionLogLevel
// Short human-readable phase label, e.g. 'Opening WebSocket'.
message: string
// Optional second line for endpoint/error/elapsed detail.
detail?: string
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
export type ConnectionState =
| 'connecting'
| 'handshaking'
@@ -60,3 +74,16 @@ export const HostProfileSchema = z.object({
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
})
// Why: persisted host record after the v0.0.3 keychain split. The
// deviceToken is held in iOS Keychain via expo-secure-store and joined
// in at load time; it must NOT appear in AsyncStorage anymore.
export const StoredHostProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
endpoint: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
})
export type StoredHostProfile = z.infer<typeof StoredHostProfileSchema>
+2 -1
View File
@@ -6,5 +6,6 @@
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
}
+42 -42
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.35-rc.4",
"version": "1.3.48-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -49,39 +49,39 @@
"@linear/sdk": "^82.1.0",
"@monaco-editor/react": "^4.7.0",
"@parcel/watcher": "^2.5.6",
"@tanstack/react-virtual": "^3.13.23",
"@tiptap/extension-code-block-lowlight": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-table": "3.22.4",
"@tiptap/extension-table-cell": "3.22.4",
"@tiptap/extension-table-header": "3.22.4",
"@tiptap/extension-table-row": "3.22.4",
"@tiptap/extension-task-item": "^3.22.4",
"@tiptap/extension-task-list": "^3.22.4",
"@tiptap/markdown": "^3.22.4",
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@xterm/addon-fit": "0.12.0-beta.198",
"@xterm/addon-ligatures": "0.11.0-beta.198",
"@xterm/addon-search": "0.17.0-beta.198",
"@xterm/addon-serialize": "0.15.0-beta.198",
"@xterm/addon-unicode11": "0.10.0-beta.198",
"@xterm/addon-web-links": "0.13.0-beta.198",
"@xterm/addon-webgl": "0.20.0-beta.197",
"@xterm/headless": "6.1.0-beta.198",
"@xterm/xterm": "6.1.0-beta.198",
"@tiptap/extension-task-item": "^3.22.5",
"@tiptap/extension-task-list": "^3.22.5",
"@tiptap/markdown": "^3.22.5",
"@tiptap/pm": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@xterm/addon-fit": "0.12.0-beta.216",
"@xterm/addon-ligatures": "0.11.0-beta.216",
"@xterm/addon-search": "0.17.0-beta.216",
"@xterm/addon-serialize": "0.15.0-beta.216",
"@xterm/addon-unicode11": "0.10.0-beta.216",
"@xterm/addon-web-links": "0.13.0-beta.216",
"@xterm/addon-webgl": "0.20.0-beta.215",
"@xterm/headless": "6.1.0-beta.216",
"@xterm/xterm": "6.1.0-beta.216",
"agent-browser": "~0.24.1",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.4.0",
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.2",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
"katex": "^0.16.45",
"lowlight": "^3.3.0",
@@ -89,8 +89,8 @@
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
"pdfjs-dist": "^5.6.205",
"posthog-node": "^5.33.0",
"pdfjs-dist": "^5.7.284",
"posthog-node": "^5.33.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
@@ -103,25 +103,25 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shadcn": "^4.1.0",
"simple-git": "^3.33.0",
"shadcn": "^4.7.0",
"simple-git": "^3.36.0",
"sonner": "^2.0.7",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"tweetnacl": "^1.0.3",
"ws": "^8.20.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"zod": "~4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron/rebuild": "^4.0.3",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@stablyai/playwright-test": "^2.1.13",
"@tailwindcss/vite": "^4.2.2",
"@stablyai/playwright-test": "^2.1.14",
"@tailwindcss/vite": "^4.2.4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -129,20 +129,20 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260406.1",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^41.2.2",
"electron": "^41.5.0",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"oxfmt": "^0.41.0",
"oxlint": "^1.56.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-grab": "^0.1.28",
"tailwindcss": "^4.2.2",
"oxlint": "^1.63.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grab": "^0.1.33",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.1"
"vite": "^7.3.2",
"vitest": "^4.1.5"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,mts,cts}": [
@@ -168,7 +168,7 @@
],
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.198": "config/patches/@xterm__addon-ligatures@0.11.0-beta.198.patch"
"@xterm/addon-ligatures@0.11.0-beta.216": "config/patches/@xterm__addon-ligatures@0.11.0-beta.216.patch"
}
}
}
+2064 -2307
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
---
name: mobile-fit-debug
description: Debug mobile terminal display-mode toggle and PTY resize issues. Use when investigating why a terminal restores to the wrong width after toggling between phone and desktop mode on mobile, or when PTY sizes are corrupted across tab switches.
---
# Mobile Fit Debug
Use this skill when debugging mobile terminal display-mode toggle issues — wrong restore width, corrupted previousCols, stale PTY sizes, or collateral safeFit cascades.
## Architecture Overview
The mobile display-mode toggle involves **3 processes** communicating asynchronously:
```
Mobile App (React Native) Server (Electron main) Desktop Renderer (Electron renderer)
───────────────────────── ────────────────────── ────────────────────────────────────
terminal.setDisplayMode ──RPC──► applyMobileDisplayMode
├─ resize PTY (authoritative)
├─ suppressResizesForMs(500)
├─ terminalFitOverrideChanged ──IPC──► setOverrideTick (React re-render)
│ ├─ safeFit on ALL panes
│ └─ pty:resize IPC ──────► SUPPRESSED
└─ notifyTerminalResize ──stream──► mobile client updates
```
## Key Files
| File | Role |
|------|------|
| `src/main/runtime/orca-runtime.ts` | Server-side state: `mobileSubscribers`, `mobileDisplayModes`, `lastRendererSizes`, suppress window. Core handlers: `handleMobileSubscribe`, `handleMobileUnsubscribe`, `applyMobileDisplayMode`, `onExternalPtyResize` |
| `src/main/ipc/pty.ts` | `pty:resize` IPC handler. Checks `isResizeSuppressed()` before processing. Calls `onExternalPtyResize` to update server state |
| `src/main/runtime/rpc/methods/terminal.ts` | RPC methods: `terminal.subscribe` (mobile subscription lifecycle), `terminal.setDisplayMode`, `terminal.getDisplayMode` |
| `src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts` | Desktop renderer's override cache. `setFitOverride('desktop-fit')` clears the override. `notifyChange` fires `onOverrideChange` listeners |
| `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` | `safeFit()` — measures pane DOM and resizes xterm. When a mobile-fit override exists, uses override dims instead of measuring |
| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | `onOverrideChange` handler triggers `setOverrideTick` re-render cascade. This is what causes safeFit to run on ALL panes |
| `mobile/app/h/[hostId]/session/[worktreeId].tsx` | Mobile session screen — tab switching, `toggleDisplayMode`, terminal subscription management |
| `src/main/runtime/mobile-subscribe-integration.test.ts` | Integration tests for the full subscribe/unsubscribe/toggle lifecycle |
## Adding Debug Logging
Production code has no debug logging. To investigate issues, temporarily add file-based logging to the key files. Here's a pattern that works well:
### 1. Add mfLog helper to each file you want to instrument
```typescript
// In orca-runtime.ts (no prefix):
import { appendFileSync } from 'fs'
function mfLog(msg: string): void {
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] ${msg}\n`) } catch {}
}
// In pty.ts (prefix [pty-ipc]):
function mfLog(msg: string): void {
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`) } catch {}
}
// In terminal.ts RPC methods (prefix [rpc]):
function mfLog(msg: string): void {
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`) } catch {}
}
```
### 2. Useful log points
**orca-runtime.ts — `handleMobileSubscribe`:**
```typescript
mfLog(`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport?.cols}x${viewport?.rows}`)
mfLog(` existing.prev=${existing?.previousCols}x${existing?.previousRows} currentSize=${currentSize?.cols}x${currentSize?.rows} rendererSize=${rendererSize?.cols}x${rendererSize?.rows}`)
mfLog(` → previousCols=${previousCols}`)
```
**orca-runtime.ts — `applyMobileDisplayMode`:**
```typescript
mfLog(`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows}`)
mfLog(` DESKTOP RESTORE: previousCols=${previousCols}`)
```
**orca-runtime.ts — `onExternalPtyResize`:**
```typescript
mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`)
```
**pty.ts — `pty:resize` handler:**
```typescript
if (runtime?.isResizeSuppressed()) {
mfLog(`pty:resize SUPPRESSED id=${args.id} cols=${args.cols} rows=${args.rows}`)
return
}
mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`)
```
**terminal.ts — `terminal.subscribe`:**
```typescript
mfLog(`\n========== MOBILE SUBSCRIBE ==========`)
mfLog(`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId} client=${params.client?.type}`)
```
### 3. Reading the log
```bash
> /tmp/mobile-fit-debug.log # clear
# ... reproduce issue on mobile ...
cat /tmp/mobile-fit-debug.log # read
```
### 4. Remember to remove logging before committing
Remove all `mfLog` functions, calls, and `appendFileSync` imports.
## What to Look For in Logs
**Wrong previousCols captured:**
```
handleMobileSubscribe ptyId=...@@abc123 ...
existing.prev=null currentSize=105x72 rendererSize=214x72 <-- rendererSize is WRONG
→ previousCols=214 <-- captured wrong value
```
This means `lastRendererSizes` was polluted by a collateral safeFit cascade.
**Suppress window too short:**
```
[pty-ipc] pty:resize id=...@@abc123 cols=105 rows=72 <-- NOT suppressed, arrived after window
```
If you see unsuppressed `pty:resize` events for terminals that weren't the one being toggled, the 500ms suppress window may be too short. Check the timestamp gap between `applyMobileDisplayMode` and the stale resize.
**Suppress working correctly:**
```
[pty-ipc] pty:resize SUPPRESSED id=...@@abc123 cols=214 rows=72
```
**Collateral cascade pattern** (the root cause of most bugs):
```
applyMobileDisplayMode ptyId=...@@split mode=desktop
DESKTOP RESTORE: previousCols=105
[pty-ipc] pty:resize SUPPRESSED id=...@@split cols=105 <-- redundant, suppressed OK
[pty-ipc] pty:resize SUPPRESSED id=...@@other cols=214 <-- collateral, suppressed OK
```
If the collateral line says `pty:resize id=` instead of `SUPPRESSED`, the cascade leaked through.
## Known Gotchas
### 1. The Collateral safeFit Cascade
When `terminalFitOverrideChanged` fires for ONE pane, the desktop renderer's `setOverrideTick` React state change triggers a re-render that runs `safeFit` on ALL panes across ALL tabs. Background-tab panes get measured at wrong widths because they may not be visible or may be in different split configurations than the active tab.
### 2. Cascade Timing is ~360ms
The full cascade path: server sends IPC -> desktop renderer receives -> React re-render -> requestAnimationFrame -> DOM measurement -> `pty:resize` IPC back to server. This takes ~360ms, which is why the suppress window must be >360ms (currently 500ms).
### 3. lastRendererSizes Persistence
`lastRendererSizes` stores every `pty:resize` from the desktop renderer. Stale values persist across mobile subscribe/unsubscribe cycles. The `lastRendererSizes.delete(ptyId)` in desktop restore is critical — without it, a stale 214 from a previous cascade gets used as `previousCols` on the next subscribe.
### 4. Daemon Doesn't Hot-Reload
The daemon process (`out/main/daemon-entry.js`) loads compiled code once at startup. Changes to `orca-runtime.ts` or `pty.ts` require:
1. `npx electron-vite build --outDir out` (or wait for watcher)
2. **Restart the dev server** (`pnpm dev`) — the daemon subprocess won't pick up new builds
Verify your code is live: `grep "your_unique_string" out/main/index.js`
### 5. previousCols Priority Chain
```typescript
previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols
```
- `existing?.previousCols` — re-subscribe case (tab switch back), most trusted
- `rendererSize?.cols` — from `lastRendererSizes`, the desktop renderer's last `pty:resize`
- `currentSize?.cols` — from `getTerminalSize()`, the server-side PTY size
If `rendererSize` is polluted (e.g. 214 for a split pane), it takes priority over the correct `currentSize`. That's why clearing `lastRendererSizes` on restore is essential.
### 6. agent-device for Automated Testing
Use `/opt/homebrew/bin/agent-device` to automate mobile UI testing:
```bash
agent-device snapshot # accessibility tree
agent-device click @e45 # click element by ref
agent-device screenshot --out /tmp/s.png # capture screenshot
agent-device wait 2 # wait 2 seconds
```
Typical test flow: click tab -> click "Switch to desktop mode" -> wait 3s -> click "Switch to phone mode" -> switch tabs -> repeat -> read `/tmp/mobile-fit-debug.log`.
## Running Tests
```bash
npx vitest run src/main/runtime/mobile-subscribe-integration.test.ts
```
+15 -3
View File
@@ -1,6 +1,18 @@
---
name: orca-cli
description: Use the Orca CLI to orchestrate worktrees, live terminals, and browser automation through a running Orca editor. Use when an agent needs to create, inspect, update, or remove Orca worktrees; inspect repo state known to Orca; read, send to, wait on, or stop Orca-managed terminals; or automate the built-in browser (navigate, snapshot, click, fill, screenshot). Coding agents should also keep the current worktree comment updated with the latest meaningful work-in-progress checkpoint whenever useful. Triggers include "use orca cli", "manage Orca worktrees", "read Orca terminal", "reply to Claude Code in Orca", "create a worktree in Orca", "update Orca worktree comment", "click on", "fill the form", "take a screenshot", "navigate to", "interact with the page", "snapshot the page", or any task where the agent should operate through Orca.
description: >-
Use the `orca` CLI to drive a running Orca editor — manage Orca worktrees;
create, read, and run shell commands in Orca-managed terminals; and automate
Orca's built-in browser (snapshot/click/fill/screenshot/tabs). Use this
instead of raw `git worktree`, ad hoc shell PTYs, or Playwright whenever the
task touches Orca state. Coding agents inside an Orca worktree should also use
it to keep the worktree comment fresh at meaningful checkpoints. Boundary with
`orchestration`: if the recipient of a terminal write is another AI agent
(Claude Code, Gemini, Codex, a worker), use `orchestration` — it is the only
correct way to send messages, nudges, replies, or task hand-offs to agents.
orca-cli writes are for non-agent terminals (shells, build/test commands);
reading or `wait`ing on any terminal — including agent terminals — stays in
orca-cli.
---
# Orca CLI
@@ -13,7 +25,7 @@ Use `orca` for:
- worktree orchestration inside a running Orca app
- updating the current worktree comment with meaningful progress checkpoints
- reading and replying to Orca-managed terminals
- reading Orca-managed terminals and sending input to non-agent terminals
- stopping or waiting on Orca-managed terminals
- accessing repos known to Orca
Do not use `orca` when plain shell tools are simpler and Orca state does not matter.
@@ -22,7 +34,7 @@ Examples:
- creating one Orca worktree per GitHub issue
- updating the current worktree comment after a significant checkpoint, such as reproducing a bug, validating a fix, or handing off for review
- finding the Claude Code terminal for a worktree and replying to it
- finding the Claude Code terminal for a worktree and reading its status
- checking which Orca worktrees have live terminal activity
## Preconditions
+15 -1
View File
@@ -1,6 +1,20 @@
---
name: orchestration
description: Use the Orca CLI to coordinate multiple coding agents via inter-agent messaging, task DAGs, dispatch with preamble injection, decision gates, and coordinator loops. Use when an agent needs to send or check inter-agent messages; create, dispatch, or track orchestration tasks; coordinate multi-agent workflows; or act as a coordinator dispatching work across terminals. Triggers include "orchestrate agents", "dispatch task", "send message to agent", "check inbox", "coordinate agents", "multi-agent", "create task DAG", "worker_done", "escalation", or any task involving inter-agent coordination through Orca.
description: >-
Use `orca orchestration` when coordinating multiple AI agents through Orca:
pass context, instructions, questions, findings, reviews, handoffs, or
structured results between agents; delegate subtasks; dispatch work; track task
DAGs; wait for worker_done/escalation; or run coordinator loops and decision
gates. This is the right skill for semantic agent-to-agent communication.
Boundary with `orca-cli`: do not use orchestration for ordinary terminal
control, shell commands, browser automation, worktree management, or
reading/waiting on terminals; use `orca-cli` for those. Triggers include "tell
the other agent", "ask <agent>", "hand off to", "pass this to <agent>",
"delegate to <agent>", "have <agent> do X", "let <agent> know", "share with the
other AI", "coordinate agents", "what did <agent> find", "check orchestration
inbox", "reply to <agent>", "dispatch task", "orchestrate agents",
"multi-agent", "task DAG", "worker_done", "escalation", "decision gate", and
"coordinator loop".
---
# Orca Inter-Agent Orchestration
+8 -1
View File
@@ -64,7 +64,14 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) {
return false
}
return !['tab list', 'tab create', 'tab current'].includes(joined)
return ![
'tab list',
'tab create',
'tab current',
'tab profile list',
'tab profile create',
'tab profile delete'
].includes(joined)
}
export function isCommandGroup(commandPath: string[]): boolean {
+26
View File
@@ -128,6 +128,32 @@ describe('orca cli browser page targeting', () => {
})
})
it('passes focus: true through to browser.tabSwitch when --focus is set', async () => {
queueFixtures(callMock, okFixture('req_switch', { switched: 1, browserPageId: 'page-1' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'switch', '--page', 'page-1', '--focus', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabSwitch', {
index: undefined,
page: 'page-1',
focus: true
})
})
it('omits focus from the payload when --focus is absent', async () => {
queueFixtures(callMock, okFixture('req_switch', { switched: 1, browserPageId: 'page-1' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['tab', 'switch', '--page', 'page-1', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenCalledTimes(1)
const call = callMock.mock.calls[0]
expect(call[0]).toBe('browser.tabSwitch')
expect(call[1]).not.toHaveProperty('focus')
})
it('passes explicit profile ids to tab create', async () => {
queueFixtures(callMock, okFixture('req_create', { browserPageId: 'page-3' }))
vi.spyOn(console, 'log').mockImplementation(() => {})
+6
View File
@@ -43,9 +43,15 @@ export const BROWSER_TAB_HANDLERS: Record<string, CommandHandler> = {
// targeted tab switches should match the rest of the --page command model:
// global by default, with --worktree only acting as explicit validation.
const target = await getBrowserCommandTarget(flags, cwd, client)
// Why: --focus is an opt-in side effect. The renderer's handler is
// worktree-scoped: it surfaces the browser pane only when the user is
// already on the targeted worktree, otherwise it pre-stages silently.
// Spread conditionally so the RPC payload stays shape-identical to the
// pre-flag form when --focus is absent.
const result = await client.call<BrowserTabSwitchResult>('browser.tabSwitch', {
index,
page,
...(flags.has('focus') ? { focus: true } : {}),
...target
})
printResult(result, json, (v) => `Switched to tab ${v.switched} (${v.browserPageId})`)
+2 -2
View File
@@ -172,8 +172,8 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [
{
path: ['tab', 'switch'],
summary: 'Switch the active browser tab',
usage: 'orca tab switch (--index <n> | --page <id>) [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree']
usage: 'orca tab switch (--index <n> | --page <id>) [--worktree <selector>] [--focus] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree', 'focus']
},
{
path: ['tab', 'create'],
@@ -0,0 +1,155 @@
// Pins the contract between `runManagedHookInstallers` and the
// `agent_hook_install_failed` telemetry event: each catch must fire `track`
// with the correct agent label and a truncated error_message, and one
// installer's failure must not stop the others (fail-open semantics).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { runManagedHookInstallers } from './install-telemetry'
describe('runManagedHookInstallers', () => {
beforeEach(() => {
trackMock.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('runs every installer when none throw and never calls track', () => {
const claude = vi.fn()
const codex = vi.fn()
runManagedHookInstallers([
['claude', claude],
['codex', codex]
])
expect(claude).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
})
it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => {
runManagedHookInstallers([
[
'codex',
() => {
throw new Error('codex config malformed')
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'codex',
error_message: 'codex config malformed'
})
})
it('continues running later installers after an earlier one throws (fail-open)', () => {
const codex = vi.fn()
const gemini = vi.fn()
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex],
['gemini', gemini]
])
expect(codex).toHaveBeenCalledTimes(1)
expect(gemini).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith(
'agent_hook_install_failed',
expect.objectContaining({ agent: 'claude' })
)
})
it('truncates error_message to 200 chars', () => {
const longMessage = 'x'.repeat(500)
runManagedHookInstallers([
[
'gemini',
() => {
throw new Error(longMessage)
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
expect(props.error_message.length).toBe(200)
})
it('handles non-Error throws', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw 'cursor string failure'
}
]
])
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: 'cursor string failure'
})
})
it('serializes thrown objects through JSON.stringify', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw { code: 'EACCES', path: '/tmp' }
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: '{"code":"EACCES","path":"/tmp"}'
})
})
it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => {
expect(() =>
runManagedHookInstallers([
[
'cursor',
() => {
throw undefined
}
]
])
).not.toThrow()
expect(trackMock).toHaveBeenCalledTimes(1)
const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
expect(eventName).toBe('agent_hook_install_failed')
expect(typeof props.error_message).toBe('string')
})
it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => {
const codex = vi.fn()
trackMock.mockImplementationOnce(() => {
throw new Error('telemetry blew up')
})
expect(() =>
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex]
])
).not.toThrow()
expect(codex).toHaveBeenCalledTimes(1)
})
})
+53
View File
@@ -0,0 +1,53 @@
// Per-agent managed-hook installer with fail-open semantics and PostHog
// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable
// without standing up the full Electron startup graph — the catch site needs
// the agent label to fire `agent_hook_install_failed`, and the previous
// closure-style loop lost it.
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
// Why: install errors are about config-file shape (malformed JSON, ACL
// denial), not user content — but messages can include paths or stack
// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message`
// in `src/shared/telemetry-events.ts`; the validator drops overlength values,
// so truncation must happen here at the call site.
const ERROR_MESSAGE_MAX_LEN = 200
export type ManagedHookInstaller = readonly [HookInstallAgent, () => void]
function describeError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
try {
const json = JSON.stringify(error)
return typeof json === 'string' ? json : String(error)
} catch {
return String(error)
}
}
export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void {
for (const [agent, install] of installers) {
try {
install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
// Why: telemetry must not break fail-open. A throw inside `track` (e.g.
// a corrupted settings store the resolveConsent path reads from) would
// otherwise abort the for-loop and skip later agents' installers.
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
}
}
+71 -2
View File
@@ -6,11 +6,18 @@ import {
readFileSync,
readdirSync,
rmSync,
writeFileSync
writeFileSync,
chmodSync
} from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createManagedCommandMatcher, writeHooksJson, type HooksConfig } from './installer-utils'
import { spawnSync } from 'child_process'
import {
createManagedCommandMatcher,
wrapPosixHookCommand,
writeHooksJson,
type HooksConfig
} from './installer-utils'
let tmpDir: string
let configPath: string
@@ -141,4 +148,66 @@ describe('createManagedCommandMatcher', () => {
it('does not match hooks for a different agent', () => {
expect(match('/bin/sh "/path/agent-hooks/gemini-hook.sh"')).toBe(false)
})
it('matches the guarded launcher form so wrapped commands sweep correctly', () => {
// Why: wrapPosixHookCommand wraps the launcher in `if [ -x ... ]; then ...; fi`
// so a stale entry no-ops instead of returning exit 127. The sweep on
// install() must still recognize the guarded form as managed, otherwise
// repeated installs would accumulate one guarded + one unguarded entry.
expect(
match(
'if [ -x "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"; fi'
)
).toBe(true)
})
})
describe('wrapPosixHookCommand', () => {
it('produces a guarded command that no-ops when the script is missing', () => {
const cmd = wrapPosixHookCommand('/does/not/exist.sh')
expect(cmd).toBe("if [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; fi")
})
it('preserves spaces in the script path (Library/Application Support case)', () => {
// Why: Electron's userData on macOS lives under "Application Support" with
// a space. The guard must keep the path quoted so `[ -x ]` and `/bin/sh`
// each see one argument.
const cmd = wrapPosixHookCommand('/Users/a/Library/Application Support/Orca/agent-hooks/x.sh')
expect(cmd).toContain("'/Users/a/Library/Application Support/Orca/agent-hooks/x.sh'")
})
it('escapes embedded single quotes so the wrapped command stays well-formed', () => {
// Why: POSIX single-quote escape renders ' as '\''. Verify a path with an
// embedded quote does not break out of the quoting and instead reaches
// /bin/sh as a single argument.
const cmd = wrapPosixHookCommand("/path/with'quote/x.sh")
expect(cmd).toBe(
"if [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; fi"
)
})
it.skipIf(process.platform === 'win32')(
'returns exit code 0 when the script does not exist (no-op)',
() => {
const cmd = wrapPosixHookCommand('/does/not/exist.sh')
const result = spawnSync('/bin/sh', ['-c', cmd])
expect(result.status).toBe(0)
}
)
// Why: commit 4d618795 explicitly switched from `&& ... || true` (which
// swallowed non-zero exits) to `if ... then ... fi` (which preserves the
// script's exit code). This test guards against a future regression that
// re-introduces the swallowing form.
it.skipIf(process.platform === 'win32')(
'propagates the script exit code when the script runs and fails',
() => {
const scriptPath = join(tmpDir, 'fails.sh')
writeFileSync(scriptPath, '#!/bin/sh\nexit 7\n', 'utf-8')
chmodSync(scriptPath, 0o755)
const cmd = wrapPosixHookCommand(scriptPath)
const result = spawnSync('/bin/sh', ['-c', cmd])
expect(result.status).toBe(7)
}
)
})
+16
View File
@@ -65,6 +65,22 @@ export function createManagedCommandMatcher(
}
}
// Why: a stale managed hook entry (left over after the user wiped userData,
// switched dev↔prod installs, or had a partial install fail) used to fire
// `/bin/sh "<missing path>"` on every tool call, which exits 127 and surfaces
// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
// transcript. Wrapping the launcher in `if [ -x ... ]; then ...; fi` makes a
// missing/non-executable script a silent no-op so a broken install never
// poisons the user's session. Failures inside the script itself are
// unaffected — only the missing-script case short-circuits.
export function wrapPosixHookCommand(scriptPath: string): string {
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
// literally — avoids a shell-injection footgun if a future caller passes an
// arbitrary path.
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi`
}
export function removeManagedCommands(
definitions: HookDefinition[],
isManagedCommand: (command: string | undefined) => boolean
+159 -105
View File
@@ -864,6 +864,154 @@ describe('Cursor hook normalization', () => {
})
})
describe('Pi hook normalization', () => {
it('before_agent_start maps to working and captures the prompt', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'before_agent_start', prompt: 'rename this fn' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.agentType).toBe('pi')
expect(result?.payload.prompt).toBe('rename this fn')
})
it('agent_start without a prompt keeps the cached prompt from the current turn', () => {
_internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'before_agent_start', prompt: 'first prompt' }),
'production'
)
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'agent_start' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('first prompt')
})
it('before_agent_start clears the previous turn’s tool cache', () => {
_internals.normalizeHookPayload(
'pi',
buildBody({
hook_event_name: 'tool_call',
tool_name: 'bash',
tool_input: { command: 'ls' }
}),
'production'
)
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'before_agent_start', prompt: 'next' }),
'production'
)
expect(result?.payload.toolName).toBeUndefined()
expect(result?.payload.toolInput).toBeUndefined()
})
it('tool_call surfaces tool_name + tool_input preview', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({
hook_event_name: 'tool_call',
tool_name: 'bash',
tool_input: { command: 'pnpm test' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('bash')
expect(result?.payload.toolInput).toBe('pnpm test')
})
it('tool_execution_start also populates the tool preview', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({
hook_event_name: 'tool_execution_start',
tool_name: 'read',
tool_input: { path: 'src/main/index.ts' }
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('read')
expect(result?.payload.toolInput).toBe('src/main/index.ts')
})
it('message_end (assistant) stays in working but captures lastAssistantMessage', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({
hook_event_name: 'message_end',
role: 'assistant',
text: 'Done — I refactored the helper.'
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.lastAssistantMessage).toBe('Done — I refactored the helper.')
})
it('message_end (user) is ignored', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }),
'production'
)
// Why: pi captures the user prompt via before_agent_start, not via
// message_end. A user-role message_end should not flip lastAssistantMessage.
expect(result?.payload.lastAssistantMessage).toBeUndefined()
})
it('agent_end maps to done', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'agent_end' }),
'production'
)
expect(result?.payload.state).toBe('done')
expect(result?.payload.agentType).toBe('pi')
})
it('session_shutdown maps to done', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'session_shutdown' }),
'production'
)
expect(result?.payload.state).toBe('done')
})
it('done preserves the cached lastAssistantMessage from a prior message_end', () => {
_internals.normalizeHookPayload(
'pi',
buildBody({
hook_event_name: 'message_end',
role: 'assistant',
text: 'final reply'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'agent_end' }),
'production'
)
expect(result?.payload.lastAssistantMessage).toBe('final reply')
})
it('unknown event names are dropped', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'never_heard_of_it' }),
'production'
)
expect(result).toBeNull()
})
})
describe('Endpoint file lifecycle', () => {
let userDataPath: string
@@ -1101,12 +1249,11 @@ describe('Last-status persistence', () => {
})
}
it('writes last-status.json after a hook event when the gate is on', async () => {
it('writes last-status.json after a hook event', async () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
await postHookEvent(
@@ -1131,58 +1278,6 @@ describe('Last-status persistence', () => {
}
})
it('skips writing when the gate is off', async () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => false
})
try {
await postHookEvent(
server,
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'gated off' })
)
server.flushStatusPersistSync()
expect(existsSync(lastStatusPath())).toBe(false)
} finally {
server.stop()
}
})
it('deletes last-status.json once after the gate flips on -> off', async () => {
let enabled = true
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => enabled
})
try {
await postHookEvent(
server,
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'before flip' })
)
server.flushStatusPersistSync()
expect(existsSync(lastStatusPath())).toBe(true)
enabled = false
// Trigger another event; the next debounced/synchronous write should
// observe the gate-off state and delete the file.
await postHookEvent(
server,
buildBody({
hook_event_name: 'PostToolUse',
tool_name: 'Read',
tool_input: { file_path: '/x' }
})
)
server.flushStatusPersistSync()
expect(existsSync(lastStatusPath())).toBe(false)
} finally {
server.stop()
}
})
it('hydrates last-status.json into the cache before listener registration', async () => {
// Pre-populate the file directly to simulate a prior session.
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
@@ -1210,8 +1305,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
const listener = vi.fn()
@@ -1248,38 +1342,6 @@ describe('Last-status persistence', () => {
}
})
it('skips hydration when the gate is off', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
[PANE]: {
paneKey: PANE,
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
payload: { state: 'done', prompt: 'should not load', agentType: 'claude' }
}
}
}),
'utf8'
)
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => false
})
try {
const listener = vi.fn()
server.setListener(listener)
expect(listener).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('treats a corrupt file as empty hydration without throwing', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(lastStatusPath(), 'not-json{{', 'utf8')
@@ -1287,8 +1349,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
const listener = vi.fn()
@@ -1322,8 +1383,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
const listener = vi.fn()
@@ -1372,8 +1432,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
const listener = vi.fn()
@@ -1421,8 +1480,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
const snapshot = server.getStatusSnapshot()
@@ -1456,8 +1514,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
expect(server.getStatusSnapshot()).toEqual([])
@@ -1470,8 +1527,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
await postHookEvent(
@@ -1495,8 +1551,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
await postHookEvent(
@@ -1525,8 +1580,7 @@ describe('Last-status persistence', () => {
const server = new AgentHookServer()
await server.start({
env: 'production',
userDataPath,
getDashboardEnabled: () => true
userDataPath
})
try {
await postHookEvent(
+125 -73
View File
@@ -23,13 +23,13 @@ import {
} from '../../shared/agent-status-types'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
// Why: Pi is intentionally absent. Pi has no shell-command hook surface —
// its extensibility is an in-process TypeScript extension API (pi.on(...)
// with events like turn_start/turn_end/tool_execution_start), not a
// settings.json hook block that we could install alongside the Claude/Codex/
// Gemini ones. Wiring Pi would require shipping a bundled Pi extension
// that POSTs to this server; until we do that, Pi panes fall back to
// terminal-title heuristics like any uninstrumented CLI.
// Why: Pi rides this server via a bundled extension (see
// pi/agent-status-extension-source) that fetch()es /hook/pi from inside
// the pi Node process. Like OpenCode, pi has no settings.json hook
// surface — its extensibility is the in-process TypeScript extension API
// (pi.on('agent_start'), 'tool_call', etc.), so the extension pre-maps
// pi's event names to the same hook_event_name vocabulary used here
// before POSTing. See normalizePiEvent below for the mapping.
//
// OpenCode rides this server via a bundled plugin (see opencode/hook-service)
// that fetch()es /hook/opencode from inside the OpenCode process. Unlike
@@ -42,7 +42,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
// conceptually similar to Claude's settings.json hooks but uses camelCase
// event names (beforeSubmitPrompt, preToolUse, postToolUse, stop, etc.) per
// https://cursor.com/docs/hooks. See normalizeCursorEvent below.
type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor'
type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi'
type AgentHookEventBasePayload = {
paneKey: string
@@ -278,7 +278,20 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
exec_command: ['cmd', 'command'],
shell_command: ['cmd', 'command'],
apply_patch: ['path', 'file_path'],
view_image: ['path', 'file_path']
view_image: ['path', 'file_path'],
// Pi tools (lowercase names matching pi's built-in tool registry).
// Why: pi's tool_call event forwards the raw input object; surface the
// canonical preview field per tool so dashboard rows show useful context
// (file path, command, search pattern) without the receiver knowing
// anything pi-specific beyond these key names. `glob` is shared with
// Gemini (same shape: { pattern }), so no separate entry is needed.
bash: ['command'],
read: ['path', 'file_path'],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
grep: ['pattern'],
web_search: ['query'],
fetch_content: ['url']
}
function deriveToolInputPreview(
@@ -667,6 +680,33 @@ function extractCursorToolFields(
return {}
}
// Why: pi's tool_call / tool_execution_start / tool_execution_end events all
// carry `tool_name` + `tool_input` in the same shape, so they share one
// extraction branch; resolveToolState merges across events last-write-wins so
// the most recent one wins naturally. message_end (assistant role) carries
// `text` — pi's analogue of Claude's last_assistant_message on Stop.
function extractPiToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (
eventName === 'tool_call' ||
eventName === 'tool_execution_start' ||
eventName === 'tool_execution_end'
) {
const toolName = readString(hookPayload, 'tool_name')
const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input)
return { toolName, toolInput }
}
if (eventName === 'message_end' && hookPayload.role === 'assistant') {
const text = readString(hookPayload, 'text')
if (text) {
return { lastAssistantMessage: text }
}
}
return {}
}
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
if (source === 'claude') {
return eventName === 'UserPromptSubmit'
@@ -686,6 +726,12 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
// not inherit any cached tool state from whatever was left on disk.
return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart'
}
if (source === 'pi') {
// Why: pi fires before_agent_start at the start of every user turn,
// carrying the fresh prompt. Reset cached tool state then so a previous
// turn's tool preview does not bleed into the new one.
return eventName === 'before_agent_start'
}
// Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the
// user's MessagePart *before* SessionBusy (message.updated fires on prompt
// submission; session.status goes busy only once OpenCode begins processing).
@@ -712,6 +758,9 @@ function extractToolFields(
if (source === 'cursor') {
return extractCursorToolFields(eventName, hookPayload)
}
if (source === 'pi') {
return extractPiToolFields(eventName, hookPayload)
}
return extractOpenCodeToolFields(eventName, hookPayload)
}
@@ -961,6 +1010,63 @@ function normalizeCursorEvent(
)
}
// Why: pi exposes an in-process TypeScript extension API (no settings.json
// hook surface). The bundled orca-agent-status extension installed into the
// per-PTY pi overlay (PiTitlebarExtensionService) translates pi's lifecycle
// events into the hook_event_name vocabulary used here so the dashboard
// row sees the same working/done shape as Claude/Codex/Gemini.
//
// Mapping:
// before_agent_start | agent_start | tool_call | tool_execution_start |
// tool_execution_end | message_end → working
// agent_end | session_shutdown → done
//
// pi has no permission-prompt event we can hook today (tool_call CAN block
// via { block: true, reason } but that's a synchronous return, not a
// separate event), so there is no `waiting` state for pi yet. message_end
// stays in `working` because the true turn-end signal is agent_end — the
// assistant message body just updates lastAssistantMessage on the
// already-active turn.
function normalizePiEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'before_agent_start' ||
eventName === 'agent_start' ||
eventName === 'tool_call' ||
eventName === 'tool_execution_start' ||
eventName === 'tool_execution_end' ||
eventName === 'message_end'
? 'working'
: eventName === 'agent_end' || eventName === 'session_shutdown'
? 'done'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('pi', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('pi', eventName)
})
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('pi', eventName)
}),
agentType: 'pi',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
if (typeof value !== 'string') {
@@ -1055,7 +1161,9 @@ function normalizeHookPayload(
? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'cursor'
? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord)
: normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'pi'
? normalizePiEvent(eventName, promptText, paneKey, hookPayloadRecord)
: normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord)
return payload ? { paneKey, tabId, worktreeId, payload } : null
}
@@ -1220,11 +1328,6 @@ export class AgentHookServer {
// tests that skip the userDataPath option) — in that case, persistence is
// a no-op and only in-memory replay applies.
private lastStatusFilePath: string | null = null
// Why: closure that reads the experimentalAgentDashboard setting at write
// time. Passed in from index.ts so the hook server stays decoupled from
// the Store class. Null fails closed: future callers must opt into disk
// persistence explicitly.
private getDashboardEnabled: (() => boolean) | null = null
// Why: trailing-edge debounce timer. captured per-instance so multiple
// server instances in the same process (tests) don't share state.
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
@@ -1232,10 +1335,6 @@ export class AgentHookServer {
// exactly match the last successful disk write. Cheap protection against
// re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
// Why: when the gate flips on → off, we delete the file once and skip
// subsequent scheduled writes until the gate flips back on. Tracking
// delete-attempted on the instance avoids re-stat'ing on every tick.
private deletedOnDisable = false
setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void {
this.onAgentStatus = listener
@@ -1269,11 +1368,7 @@ export class AgentHookServer {
}
}
async start(options?: {
env?: string
userDataPath?: string
getDashboardEnabled?: () => boolean
}): Promise<void> {
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
if (this.server) {
return
}
@@ -1286,18 +1381,14 @@ export class AgentHookServer {
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME)
}
if (options?.getDashboardEnabled) {
this.getDashboardEnabled = options.getDashboardEnabled
}
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
this.deletedOnDisable = false
// Why: hydrate before binding the HTTP listener so any new hook POST
// (which goes through lastStatusByPaneKey.set) runs against an already-
// populated map. The renderer later pulls this map as a snapshot after
// its settings and workspace tabs are hydrated.
if (this.lastStatusFilePath && this.isDashboardEnabled()) {
// workspace tabs are hydrated.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
@@ -1337,7 +1428,9 @@ export class AgentHookServer {
? 'opencode'
: pathname === '/hook/cursor'
? 'cursor'
: null
: pathname === '/hook/pi'
? 'pi'
: null
if (!source) {
res.writeHead(404)
res.end()
@@ -1419,9 +1512,7 @@ export class AgentHookServer {
this.endpointFilePathCache = null
this.endpointFileWritten = false
this.lastStatusFilePath = null
this.getDashboardEnabled = null
this.lastWrittenJson = null
this.deletedOnDisable = false
// Why: drop all per-pane cache entries on shutdown so a subsequent start()
// in the same process (e.g. during tests or a settings-driven restart)
// does not inherit stale prompt/tool state from the previous run.
@@ -1627,14 +1718,6 @@ export class AgentHookServer {
}
}
// Why: fail closed when the gate isn't wired so a future caller of start()
// that forgets the closure cannot silently leak hook payloads to disk.
// Production main always passes the closure; tests that need persistence
// pass `getDashboardEnabled: () => true` explicitly.
private isDashboardEnabled(): boolean {
return this.getDashboardEnabled?.() === true
}
private hydrateLastStatusFromDisk(): void {
if (!this.lastStatusFilePath) {
return
@@ -1643,7 +1726,7 @@ export class AgentHookServer {
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
} catch (err) {
// Why: missing file is the common case (first launch with the gate on).
// Why: missing file is the common case (first launch).
// Other errors (EACCES, etc.) degrade to empty hydration with a single
// warn so the dashboard renders normally.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
@@ -1754,37 +1837,6 @@ export class AgentHookServer {
if (!this.lastStatusFilePath || !this.endpointDir) {
return
}
if (!this.isDashboardEnabled()) {
// Why: when the gate flips off, delete the existing file once so the
// user has no hook-payload data on disk. Subsequent ticks no-op until
// the gate flips back on; tracking deletedOnDisable on the instance
// avoids re-stat'ing every tick.
if (this.deletedOnDisable) {
return
}
// Why: a transient unlink failure must not permanently suppress retries —
// the gate is OFF and the file must come off disk on a future tick.
let removed = false
try {
unlinkSync(this.lastStatusFilePath)
removed = true
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
removed = true
} else {
console.warn('[agent-hooks] failed to delete last-status file:', err)
}
}
if (removed) {
this.deletedOnDisable = true
this.lastWrittenJson = null
}
return
}
// Why: the gate is back on after being off — clear the suppression
// flag so the next on→off transition deletes the freshly-written file
// again instead of being skipped.
this.deletedOnDisable = false
const json = this.serializeStatusFile()
if (json === this.lastWrittenJson) {
return
+10 -8
View File
@@ -1,7 +1,8 @@
import { screen, webContents } from 'electron'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
normalizeExternalBrowserUrl,
redactKagiSessionToken
} from '../../shared/browser-url'
import {
isWindowShortcutModifierChord,
@@ -31,7 +32,10 @@ export function setupGuestContextMenu(args: {
if (!renderer) {
return
}
const pageUrl = guest.getURL()
// Why: redact Kagi session tokens before the URL leaves main; the renderer
// pipes pageUrl into clipboard writes and shell.openExternal, both of which
// would otherwise expose the bearer token outside Orca.
const pageUrl = redactKagiSessionToken(guest.getURL())
// Why: params.linkURL is empty when the user right-clicks non-link
// content. Normalizing an empty string through normalizeBrowserNavigationUrl
// produces the blank-page constant (a truthy string), which would trick the
@@ -280,12 +284,10 @@ export function setupGuestShortcutForwarding(args: {
if (input.code === 'KeyB' && input.shift) {
renderer.send('ui:newBrowserTab')
} else if (input.code === 'KeyT' && !input.shift) {
// Why: once focus is inside a browser guest, Cmd/Ctrl+T should extend
// the current browser workspace with another internal page instead of
// creating a sibling Orca terminal tab. The renderer still decides
// whether that means "new page in this workspace" or "new workspace"
// based on the current active surface.
renderer.send('ui:newBrowserTab')
// Why: Cmd/Ctrl+T always opens a new terminal in the central pane,
// even when focus is inside a browser guest. Cmd/Ctrl+Shift+B is the
// dedicated shortcut for new browser tabs.
renderer.send('ui:newTerminalTab')
} else if (input.code === 'KeyL' && !input.shift) {
// Why: the address bar lives in the renderer chrome, not the guest
// page. Forward Cmd/Ctrl+L out of the guest so the active BrowserPane
+1 -1
View File
@@ -1093,7 +1093,7 @@ describe('browserManager', () => {
}
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:newBrowserTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newBrowserTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newTerminalTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:switchTab', 1)
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:switchTerminalTab', 1)
+13 -3
View File
@@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto'
import { shell, webContents } from 'electron'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
normalizeExternalBrowserUrl,
redactKagiSessionToken
} from '../../shared/browser-url'
import type {
BrowserDownloadFinishedEvent,
@@ -445,7 +446,10 @@ export class BrowserManager {
action: 'opened-in-orca'
})
} else if (externalUrl) {
void shell.openExternal(externalUrl)
// Why: a target=_blank click on a Kagi search result page produces a
// popup URL that still contains the bearer token; redact before
// handing the URL to the OS default browser.
void shell.openExternal(redactKagiSessionToken(externalUrl))
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(externalUrl),
action: 'opened-external'
@@ -1425,9 +1429,15 @@ export class BrowserManager {
return
}
// Why: redact Kagi session tokens before the validated URL is persisted
// by the renderer into BrowserPage.loadError (saved to disk via the
// workspace session writer).
renderer.send('browser:guest-load-failed', {
browserPageId: browserTabId,
loadError
loadError: {
...loadError,
validatedUrl: redactKagiSessionToken(loadError.validatedUrl)
}
})
}
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getDefaultSettings } from '../../shared/constants'
import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types'
const testState = {
userDataDir: '',
fakeHomeDir: '',
activeKeychainCredentials: null as string | null,
managedKeychainCredentials: new Map<string, string>()
}
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
vi.mock('./keychain', () => ({
readActiveClaudeKeychainCredentials: vi.fn(async () => testState.activeKeychainCredentials),
writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string) => {
testState.activeKeychainCredentials = contents
}),
deleteActiveClaudeKeychainCredentials: vi.fn(async () => {
testState.activeKeychainCredentials = null
}),
readManagedClaudeKeychainCredentials: vi.fn(
async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null
),
writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => {
testState.managedKeychainCredentials.set(accountId, contents)
})
}))
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
...getDefaultSettings(testState.fakeHomeDir),
...overrides
}
}
function createStore(settings: GlobalSettings) {
return {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
settings = {
...settings,
...updates,
notifications: {
...settings.notifications,
...updates.notifications
}
}
return settings
})
}
}
function createManagedClaudeAuth(
rootDir: string,
accountId: string,
credentialsJson: string
): string {
const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), `{"accountUuid":"${accountId}"}\n`)
testState.managedKeychainCredentials.set(accountId, credentialsJson)
return managedAuthPath
}
function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManagedAccount {
return {
id,
email: 'user@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
}
describe('ClaudeRuntimeAuthService', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
testState.activeKeychainCredentials = null
testState.managedKeychainCredentials.clear()
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-'))
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-'))
mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true })
})
afterEach(() => {
rmSync(testState.userDataDir, { recursive: true, force: true })
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
})
it('rematerializes unchanged managed credentials when the runtime file is missing', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
'{"token":"managed"}\n'
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n')
rmSync(runtimeCredentialsPath, { force: true })
await service.prepareForClaudeLaunch()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n')
})
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
if (process.platform === 'win32') {
return
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
'{"token":"managed"}\n'
)
const settings = createSettings({
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
activeClaudeManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n')
writeFileSync(join(managedAuthPath, '.credentials.json'), '{"token":"rotated"}\n', 'utf-8')
chmodSync(runtimeCredentialsPath, 0o000)
try {
await service.syncForCurrentSelection()
} finally {
if (existsSync(runtimeCredentialsPath)) {
chmodSync(runtimeCredentialsPath, 0o600)
}
warn.mockRestore()
}
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n')
})
})
@@ -314,13 +314,35 @@ export class ClaudeRuntimeAuthService {
private writeRuntimeCredentials(contents: string): void {
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
mkdirSync(dirname(credentialsPath), { recursive: true })
// Why: repeated Claude spawns sync auth, but credentials rarely change.
// Skipping unchanged rewrites avoids Windows EPERM contention in #1507.
if (this.lastWrittenCredentialsJson === contents && existsSync(credentialsPath)) {
return
}
if (this.fileContentsEqual(credentialsPath, contents)) {
this.lastWrittenCredentialsJson = contents
return
}
writeFileAtomically(credentialsPath, contents, { mode: 0o600 })
this.lastWrittenCredentialsJson = contents
}
private writeJson(targetPath: string, value: unknown): void {
const serialized = `${JSON.stringify(value, null, 2)}\n`
mkdirSync(dirname(targetPath), { recursive: true })
writeFileAtomically(targetPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
// Why: same Windows contention reason as writeRuntimeCredentials.
if (this.fileContentsEqual(targetPath, serialized)) {
return
}
writeFileAtomically(targetPath, serialized, { mode: 0o600 })
}
private fileContentsEqual(targetPath: string, contents: string): boolean {
try {
return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents
} catch {
return false
}
}
private readJsonObject(targetPath: string): Record<string, unknown> {
+54
View File
@@ -177,4 +177,58 @@ describe('ClaudeUsageStore', () => {
expect(summary.turns).toBe(5)
expect(summary.zeroCacheReadTurns).toBe(2)
})
it('prices Claude Opus 4.7 with current Anthropic rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-opus-4-7-20260416',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 1_000_000
}
]
})
const summary = await store.getSummary('orca', '30d')
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(summary.estimatedCostUsd).toBeCloseTo(36.75)
expect(
breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd
).toBeCloseTo(36.75)
})
it('does not collapse older Opus 4.1 usage into current Opus 4.7 pricing', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-opus-4-1-20250805',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 1_000_000
}
]
})
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(110.25)
})
})
+45 -45
View File
@@ -2,7 +2,6 @@
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import type { Repo } from '../../shared/types'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -13,16 +12,10 @@ import type {
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import { mergeWorktree } from '../ipc/worktree-logic'
import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import {
createWorktreeRefs,
getDefaultWorktreeLabel,
getSessionProjectLabel,
scanClaudeUsageFiles
} from './scanner'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
const SCHEMA_VERSION = 1
const STALE_MS = 5 * 60_000
@@ -35,11 +28,18 @@ const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
'claude-opus-4-6': { input: 6.15, output: 30.75, cacheRead: 0.61, cacheWrite: 7.69 },
'claude-opus-4-5': { input: 6.15, output: 30.75, cacheRead: 0.61, cacheWrite: 7.69 },
'claude-sonnet-4-6': { input: 3.69, output: 18.45, cacheRead: 0.37, cacheWrite: 4.61 },
'claude-sonnet-4-5': { input: 3.69, output: 18.45, cacheRead: 0.37, cacheWrite: 4.61 },
'claude-haiku-4-5': { input: 1.23, output: 6.15, cacheRead: 0.12, cacheWrite: 1.54 }
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-4': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
}
function getDefaultState(): ClaudeUsagePersistedState {
@@ -73,15 +73,42 @@ function normalizeModelForPricing(model: string | null): string | null {
return null
}
const lower = model.toLowerCase()
if (lower.includes('opus')) {
if (lower.includes('opus-4-7')) {
return 'claude-opus-4-7'
}
if (lower.includes('opus-4-6')) {
return 'claude-opus-4-6'
}
if (lower.includes('sonnet')) {
if (lower.includes('opus-4-5')) {
return 'claude-opus-4-5'
}
if (lower.includes('opus-4-1')) {
return 'claude-opus-4-1'
}
if (lower.includes('opus-4')) {
return 'claude-opus-4'
}
if (lower.includes('sonnet-4-6')) {
return 'claude-sonnet-4-6'
}
if (lower.includes('haiku')) {
if (lower.includes('sonnet-4-5')) {
return 'claude-sonnet-4-5'
}
if (lower.includes('sonnet-4')) {
return 'claude-sonnet-4'
}
if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) {
return 'claude-sonnet-3-7'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
}
if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) {
return 'claude-haiku-3-5'
}
if (lower.includes('haiku-3')) {
return 'claude-haiku-3'
}
return null
}
@@ -131,12 +158,6 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
type ClaudeUsageWorktree = {
worktreeId: string
path: string
displayName: string
}
export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
@@ -226,7 +247,7 @@ export class ClaudeUsageStore {
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo))
this.state.processedFiles = result.processedFiles
this.state.sessions = result.sessions
@@ -502,25 +523,4 @@ export class ClaudeUsageStore {
return true
})
}
private async loadWorktreesByRepo(repos: Repo[]): Promise<Map<string, ClaudeUsageWorktree[]>> {
const worktreesByRepo = new Map<string, ClaudeUsageWorktree[]>()
for (const repo of repos) {
const gitWorktrees = await listRepoWorktrees(repo)
const mapped = gitWorktrees.map((worktree) => {
const worktreeId = `${repo.id}::${worktree.path}`
const meta = this.store.getWorktreeMeta(worktreeId)
const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName)
return {
worktreeId,
path: worktree.path,
displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path)
}
})
worktreesByRepo.set(repo.id, mapped)
}
return worktreesByRepo
}
}
+11 -7
View File
@@ -6,6 +6,7 @@ import {
createManagedCommandMatcher,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -48,13 +49,16 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
// Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`).
// A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is
// interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc.
// collapse and the launcher fails with `command not found`. Emit forward
// slashes — Windows accepts them in path arguments and bash leaves them
// intact, so the same JSON value works through every shell layer.
return process.platform === 'win32' ? scriptPath.replaceAll('\\', '/') : `/bin/sh "${scriptPath}"`
if (process.platform === 'win32') {
// Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`).
// A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is
// interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc.
// collapse and the launcher fails with `command not found`. Emit forward
// slashes — Windows accepts them in path arguments and bash leaves them
// intact, so the same JSON value works through every shell layer.
return scriptPath.replaceAll('\\', '/')
}
return wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
+15 -10
View File
@@ -39,26 +39,31 @@ export function writeFileAtomically(
}
}
// Why: on Windows, renameSync can fail with EPERM/EACCES if another process
// (antivirus, Codex CLI) holds the target file open. A short retry avoids
// transient failures without masking real permission errors.
// Why: on Windows, renameSync can fail with EPERM/EACCES/EBUSY if another
// process (antivirus, Claude CLI, Codex CLI) holds the target file open.
// A short retry avoids transient failures without masking real permission
// errors. Total backoff (~750ms) covers typical AV scan windows seen in
// issue #1507.
function renameWithRetry(source: string, target: string): void {
const maxAttempts = process.platform === 'win32' ? 3 : 1
const maxAttempts = process.platform === 'win32' ? 6 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
renameSync(source, target)
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES')) {
const delayMs = attempt * 50
const until = Date.now() + delayMs
while (Date.now() < until) {
/* busy-wait: setTimeout is async and callers must stay sync */
}
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) {
sleepSync(attempt * 50)
continue
}
throw error
}
}
}
// Why: writeFileAtomically is a sync API called from sync paths, so the retry
// backoff must park the thread instead of burning CPU in a Date.now() loop.
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
function sleepSync(ms: number): void {
Atomics.wait(sleepBuffer, 0, 0, ms)
}
@@ -66,7 +66,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showTitlebarAppName: true,
showTasksButton: true,
diffDefaultView: 'inline',
notifications: {
@@ -95,9 +95,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalMobile: false,
experimentalSidekick: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
+3 -3
View File
@@ -60,7 +60,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showTitlebarAppName: true,
showTasksButton: true,
diffDefaultView: 'inline',
notifications: {
@@ -89,9 +89,9 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalMobile: false,
experimentalSidekick: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
+106
View File
@@ -164,6 +164,112 @@ describe('CodexUsageStore', () => {
expect(summary.reasoningOutputTokens).toBe(100)
})
it('prices current Codex models with current model rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'gpt-5.2-codex',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
},
{
day: '2026-04-09',
model: 'gpt-5.3-codex',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
},
{
day: '2026-04-09',
model: 'gpt-5.4',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
},
{
day: '2026-04-09',
model: 'gpt-5.5',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
}
]
})
const summary = await store.getSummary('orca', '30d')
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(summary.estimatedCostUsd).toBeCloseTo(85.1)
expect(breakdown.find((row) => row.key === 'gpt-5.2-codex')?.estimatedCostUsd).toBeCloseTo(
15.925
)
expect(breakdown.find((row) => row.key === 'gpt-5.3-codex')?.estimatedCostUsd).toBeCloseTo(
15.925
)
expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(17.75)
expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(35.5)
})
it('keeps cached input out of the full-price input bucket for GPT-5.5 totals', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'gpt-5.5',
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 491_053_514,
cachedInputTokens: 459_283_584,
outputTokens: 1_944_952,
reasoningOutputTokens: 551_764,
totalTokens: 492_998_466,
hasInferredPricing: false
}
]
})
const summary = await store.getSummary('orca', '30d')
expect(summary.estimatedCostUsd).toBeCloseTo(446.840002)
})
it('counts mixed-model sessions once for each real model row in the breakdown', async () => {
const store = createStoreWithState({
sessions: [
+22 -39
View File
@@ -2,7 +2,6 @@
import { app } from 'electron'
import { dirname, join } from 'path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import type { Repo } from '../../shared/types'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@@ -13,11 +12,10 @@ import type {
CodexUsageSessionRow,
CodexUsageSummary
} from '../../shared/codex-usage-types'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import { mergeWorktree } from '../ipc/worktree-logic'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, getDefaultWorktreeLabel, scanCodexUsageFiles } from './scanner'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
const SCHEMA_VERSION = 2
const STALE_MS = 5 * 60_000
@@ -26,8 +24,11 @@ let _codexUsageFile: string | null = null
const MODEL_PRICING: Record<string, { input: number; cachedInput: number; output: number }> = {
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex': { input: 1.9, cachedInput: 0.19, output: 15 }
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.4': { input: 2.5, cachedInput: 0.25, output: 15 },
'gpt-5.5': { input: 5, cachedInput: 0.5, output: 30 }
}
function getDefaultState(): CodexUsagePersistedState {
@@ -80,15 +81,24 @@ function normalizeModelForPricing(model: string | null): string | null {
}
const lower = model.toLowerCase()
if (lower === 'gpt-5' || lower === 'gpt-5-codex' || lower.startsWith('gpt-5.4')) {
if (lower === 'gpt-5' || lower === 'gpt-5-codex') {
return 'gpt-5'
}
if (lower.startsWith('gpt-5.2-codex')) {
return 'gpt-5.2-codex'
if (lower.startsWith('gpt-5.1')) {
return 'gpt-5.1'
}
if (lower.startsWith('gpt-5.2')) {
return 'gpt-5.2'
}
if (lower.startsWith('gpt-5.3-codex')) {
return 'gpt-5.3-codex'
}
if (lower.startsWith('gpt-5.4')) {
return 'gpt-5.4'
}
if (lower.startsWith('gpt-5.5')) {
return 'gpt-5.5'
}
return null
}
@@ -141,12 +151,6 @@ function getLocalDay(timestamp: string): string | null {
return `${year}-${month}-${day}`
}
type CodexUsageWorktree = {
worktreeId: string
path: string
displayName: string
}
type ScopedCodexUsageModelRow = {
modelKey: string
modelLabel: string
@@ -159,7 +163,7 @@ type ScopedCodexUsageModelRow = {
totalTokens: number
}
function getWorktreeFingerprint(worktreesByRepo: Map<string, CodexUsageWorktree[]>): string {
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
const rows = [...worktreesByRepo.entries()]
.flatMap(([repoId, worktrees]) =>
worktrees.map((worktree) =>
@@ -259,7 +263,7 @@ export class CodexUsageStore {
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
const result = await scanCodexUsageFiles(
createWorktreeRefs(repos, worktreesByRepo),
@@ -591,28 +595,7 @@ export class CodexUsageStore {
private async getCurrentWorktreeFingerprint(): Promise<string> {
const repos = this.store.getRepos()
const worktreesByRepo = await this.loadWorktreesByRepo(repos)
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
private async loadWorktreesByRepo(repos: Repo[]): Promise<Map<string, CodexUsageWorktree[]>> {
const worktreesByRepo = new Map<string, CodexUsageWorktree[]>()
for (const repo of repos) {
const gitWorktrees = await listRepoWorktrees(repo)
const mapped = gitWorktrees.map((worktree) => {
const worktreeId = `${repo.id}::${worktree.path}`
const meta = this.store.getWorktreeMeta(worktreeId)
const merged = mergeWorktree(repo.id, worktree, meta, repo.displayName)
return {
worktreeId,
path: worktree.path,
displayName: merged.displayName || getDefaultWorktreeLabel(worktree.path)
}
})
worktreesByRepo.set(repo.id, mapped)
}
return worktreesByRepo
}
}
+973
View File
@@ -0,0 +1,973 @@
/* eslint-disable max-lines -- Why: this suite keeps the hash fixture, TOML edit edge cases, and trust-state parser regressions together so Codex compatibility failures are easy to audit. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
computeTrustKey,
computeTrustedHash,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
upsertHookTrustEntries,
type CodexTrustEntry
} from './config-toml-trust'
// Why: this hash was captured from a real Codex 0.129 `/hooks` approval. If
// Codex changes its serialization or normalization rules, this test fails
// loudly instead of silently shipping bad trust entries that put hooks back
// into the review pile.
const REAL_APPROVED_COMMAND = '/bin/sh "/tmp/orca-case-b-mCmCe6/agent-hooks/codex-hook.sh"'
const REAL_APPROVED_HASH = 'sha256:bc013489dba495431d3790fda62ee5a7d907a7c491e29ad26238c3a5d6d2b163'
let tmpDir: string
let configPath: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-codex-trust-test-'))
configPath = join(tmpDir, 'config.toml')
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
describe('computeTrustedHash', () => {
it('reproduces the hash that Codex /hooks wrote for a real approval', () => {
expect(
computeTrustedHash({
sourcePath: '/Users/thebr/.codex/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: REAL_APPROVED_COMMAND
})
).toBe(REAL_APPROVED_HASH)
})
it('produces a different hash when the command changes', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'bar'
})
expect(a).not.toBe(b)
})
it('produces a different hash when the event label changes', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'post_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
expect(a).not.toBe(b)
})
it('ignores groupIndex/handlerIndex (those are part of the key, not the hash)', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 99,
handlerIndex: 99,
command: 'foo'
})
expect(a).toBe(b)
})
it('hashes a missing matcher the same as no matcher field', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
matcher: undefined
})
expect(a).toBe(b)
})
it('produces a different hash when matcher is set', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
matcher: 'foo'
})
expect(a).not.toBe(b)
})
it('produces a different hash when statusMessage is set', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
statusMessage: 'msg'
})
expect(a).not.toBe(b)
})
it('produces a different hash when async flips from default false to true', () => {
const a = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
async: false
})
const b = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
async: true
})
expect(a).not.toBe(b)
})
it('clamps timeoutSec=0 to 1 (which differs from the unset default of 600)', () => {
const zero = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
timeoutSec: 0
})
const one = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo',
timeoutSec: 1
})
const unset = computeTrustedHash({
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'foo'
})
expect(zero).toBe(one)
expect(zero).not.toBe(unset)
})
})
describe('computeTrustKey', () => {
it('joins source path, event label, group index, handler index with colons', () => {
expect(
computeTrustKey({
sourcePath: '/Users/thebr/.codex/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'irrelevant'
})
).toBe('/Users/thebr/.codex/hooks.json:pre_tool_use:0:0')
})
})
describe('upsertHookTrustEntries', () => {
it('creates the file with a trust block when none exists', () => {
const entry: CodexTrustEntry = {
sourcePath: '/foo/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: '/bin/echo hi'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(`[hooks.state."/foo/hooks.json:pre_tool_use:0:0"]`)
expect(written).toContain('enabled = true')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('appends to an existing config without disturbing prior content', () => {
const original = [
'model = "gpt-5.5"',
'approval_policy = "never"',
'',
'[features]',
'hooks = true',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'session_start',
groupIndex: 0,
handlerIndex: 0,
command: 'echo hello'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written.startsWith(original.trimEnd())).toBe(true)
expect(written).toContain('[hooks.state."/x/hooks.json:session_start:0:0"]')
})
it('replaces an existing block keyed at the same path without touching unrelated blocks', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'[features]',
'hooks = true',
'',
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:STALE"',
'',
'[unrelated]',
'value = 42',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo new'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain('STALE')
expect(written).toContain('[unrelated]')
expect(written).toContain('value = 42')
// Why: we only own the [hooks.state."<key>"] block — the [features]
// block must be unchanged.
expect(written).toContain('[features]\nhooks = true')
})
it('writes a single block per entry even when called repeatedly', () => {
const entry: CodexTrustEntry = {
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
upsertHookTrustEntries(configPath, [entry])
upsertHookTrustEntries(configPath, [entry])
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
const occurrences = written.match(/\[hooks\.state\./g) ?? []
expect(occurrences).toHaveLength(1)
})
it('writes a .bak file before overwriting an existing config', () => {
writeFileSync(configPath, 'model = "old"\n', 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
])
expect(existsSync(`${configPath}.bak`)).toBe(true)
expect(readFileSync(`${configPath}.bak`, 'utf-8')).toBe('model = "old"\n')
})
it('does not write at all when the file already has the right hash', () => {
const entry: CodexTrustEntry = {
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
upsertHookTrustEntries(configPath, [entry])
const firstWrite = readFileSync(configPath, 'utf-8')
// Why: a no-op upsert must not roll the .bak forward — repeated calls
// (e.g. from app start) would otherwise destroy the last recoverable copy.
rmSync(`${configPath}.bak`, { force: true })
upsertHookTrustEntries(configPath, [entry])
expect(existsSync(`${configPath}.bak`)).toBe(false)
expect(readFileSync(configPath, 'utf-8')).toBe(firstWrite)
})
it('replaces a stale block written with CRLF line endings without duplicating', () => {
// Why: regression — Windows-style \r\n in the existing config previously
// caused the header pattern to miss and append a duplicate block.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'[features]',
'hooks = true',
'',
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:STALE"',
''
].join('\r\n')
writeFileSync(configPath, original, 'utf-8')
const entry: CodexTrustEntry = {
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo new'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
const occurrences = written.match(/\[hooks\.state\./g) ?? []
expect(occurrences).toHaveLength(1)
expect(written).not.toContain('STALE')
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
})
it('preserves an immediately-adjacent unrelated hooks.state block', () => {
const targetKey = '/x/hooks.json:pre_tool_use:0:0'
const neighborKey = '/y/hooks.json:post_tool_use:0:0'
const original = [
`[hooks.state."${targetKey}"]`,
'enabled = true',
'trusted_hash = "sha256:STALE"',
`[hooks.state."${neighborKey}"]`,
'enabled = true',
'trusted_hash = "sha256:NEIGHBOR"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo new'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain('STALE')
expect(written).toContain(`[hooks.state."${neighborKey}"]`)
expect(written).toContain('trusted_hash = "sha256:NEIGHBOR"')
// Neighbor's `enabled = true` should still be paired with NEIGHBOR's hash.
const neighborIdx = written.indexOf(`[hooks.state."${neighborKey}"]`)
expect(written.slice(neighborIdx)).toMatch(/enabled = true[\s\S]*sha256:NEIGHBOR/)
})
it('preserves an unrelated table whose quoted key contains a `]`', () => {
const original = ['[other."a]b"]', 'foo = 1', ''].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain('[other."a]b"]')
expect(written).toContain('foo = 1')
})
// Why: TOML supports both basic-string and literal-string quoted keys;
// header detection must respect `]` inside `'...'` too.
it('preserves an unrelated table whose literal-string key contains a `]`', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:STALE"',
"[other.'a]b']",
'foo = 1',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo new'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain('STALE')
expect(written).toContain("[other.'a]b']")
expect(written).toContain('foo = 1')
})
it('does not treat `[fake]` inside a multi-line basic string as a header', () => {
const original = [
'model = "gpt"',
'description = """',
'This text has a fake header:',
'[fake]',
'inside it.',
'"""',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(
['description = """', 'This text has a fake header:', '[fake]', 'inside it.', '"""'].join(
'\n'
)
)
})
it('treats `\\"""` inside a multi-line basic string as an escaped quote, not a close', () => {
// Why: a basic multi-line string with `\"` escapes must not be misread as
// closing early — content and any following real header must survive intact.
const original = [
'prompt = """',
'use \\"\\"\\" carefully',
'"""',
'',
'[other]',
'x = 1',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(['prompt = """', 'use \\"\\"\\" carefully', '"""'].join('\n'))
expect(written).toContain('[other]\nx = 1')
expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]')
})
it('escapes literal `"` and `\\` in the source path inside the trust block header', () => {
const entry: CodexTrustEntry = {
sourcePath: '/x/with"quote\\and\\back/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
upsertHookTrustEntries(configPath, [entry])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(
`[hooks.state."/x/with\\"quote\\\\and\\\\back/hooks.json:pre_tool_use:0:0"]`
)
})
it('overwrites an existing block whose header has leading whitespace (TOML allows indent)', () => {
// Why: regression — buildHeaderPattern used to require column-0 headers,
// but the reader accepts indented ones. That mismatch caused upsert to
// append a duplicate `[hooks.state."<key>"]` block, producing invalid TOML.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = ` [hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo hi'
}
])
const written = readFileSync(configPath, 'utf-8')
const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
it('preserves `enabled = false` when the user hand-edited it before reinstall', () => {
// Why: regression — auto-install on app start used to clobber a
// hand-disabled hook back to enabled = true, removing the only way to
// mute Orca's hook short of full uninstall.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"]\nenabled = false\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo hi'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain('enabled = false')
expect(written).not.toContain('enabled = true')
})
it('overwrites an existing block when the file ends without a trailing newline', () => {
// Why: regression — buildHeaderPattern used to require a trailing
// `\r?\n`, missing it caused the upsert path to take the no-match branch
// and append a duplicate block at EOF.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"`
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo hi'
}
])
const written = readFileSync(configPath, 'utf-8')
const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
it('overwrites an existing block whose header has an inline comment', () => {
// Why: regression — buildHeaderPattern used to require the header line
// to end at \r?\n or EOF, missing TOML-valid trailing comments. The
// upsert path then took the no-match branch and appended a duplicate
// `[hooks.state."<key>"]` block, producing invalid TOML.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo hi'
}
])
const written = readFileSync(configPath, 'utf-8')
const headerCount = (written.match(/\[hooks\.state\."/g) ?? []).length
expect(headerCount).toBe(1)
expect(written).not.toContain('sha256:OLD')
})
})
describe('removeHookTrustEntries', () => {
it('is a no-op (creates no file) when the config does not exist', () => {
removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0'])
expect(existsSync(configPath)).toBe(false)
})
it('does not roll a .bak forward when the requested key is not present', () => {
const original = ['[features]', 'hooks = true', ''].join('\n')
writeFileSync(configPath, original, 'utf-8')
removeHookTrustEntries(configPath, ['/missing/hooks.json:pre_tool_use:0:0'])
expect(readFileSync(configPath, 'utf-8')).toBe(original)
expect(existsSync(`${configPath}.bak`)).toBe(false)
})
it('removes a single block while leaving unrelated tables intact', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'[features]',
'hooks = true',
'',
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:KEEP"',
'',
'[unrelated]',
'value = 42',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
removeHookTrustEntries(configPath, [key])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state."${key}"]`)
expect(written).not.toContain('sha256:KEEP')
expect(written).toContain('[features]\nhooks = true')
expect(written).toContain('[unrelated]\nvalue = 42')
})
it('preserves the line separator when no blank line precedes the removed block', () => {
// Why: regression — removeTrustBlock used to cut from match.index (the
// captured leading newline) and fused the previous content into the next
// header, producing invalid TOML like `a = 1[other]`.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'a = 1',
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:K"',
'[other]',
'b = 2',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
removeHookTrustEntries(configPath, [key])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain('a = 1[other]')
expect(written).toContain('a = 1\n[other]')
})
it('removes multiple blocks in a single call', () => {
const keyA = '/x/hooks.json:pre_tool_use:0:0'
const keyB = '/x/hooks.json:post_tool_use:0:0'
const original = [
`[hooks.state."${keyA}"]`,
'enabled = true',
'trusted_hash = "sha256:A"',
'',
`[hooks.state."${keyB}"]`,
'enabled = true',
'trusted_hash = "sha256:B"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
removeHookTrustEntries(configPath, [keyA, keyB])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state."${keyA}"]`)
expect(written).not.toContain(`[hooks.state."${keyB}"]`)
expect(written).not.toContain('sha256:A')
expect(written).not.toContain('sha256:B')
})
it('removes a block whose header has an inline comment', () => {
// Why: paired with the upsert regression; the same pattern mismatch
// would silently leave the dead block in place during uninstall.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:K"\n`
writeFileSync(configPath, original, 'utf-8')
removeHookTrustEntries(configPath, [key])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain(`[hooks.state."${key}"]`)
})
})
describe('readHookTrustEntries', () => {
it('returns an empty map when the file does not exist', () => {
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(0)
})
it('returns key→hash entries for each [hooks.state."<key>"] block', () => {
const keyA = '/x/hooks.json:pre_tool_use:0:0'
const keyB = '/y/hooks.json:post_tool_use:1:0'
const original = [
`[hooks.state."${keyA}"]`,
'enabled = true',
'trusted_hash = "sha256:AAA"',
'',
`[hooks.state."${keyB}"]`,
'enabled = true',
'trusted_hash = "sha256:BBB"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(2)
expect(result.get(keyA)?.trustedHash).toBe('sha256:AAA')
expect(result.get(keyA)?.enabled).toBe(true)
expect(result.get(keyB)?.trustedHash).toBe('sha256:BBB')
expect(result.get(keyB)?.enabled).toBe(true)
})
it('unescapes `\\\\` in the block key', () => {
// Why: a real Windows path on disk like C:\foo gets written escaped as
// `C:\\foo` inside the TOML key — the returned Map should expose the
// original unescaped form.
const original = [
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
'enabled = true',
'trusted_hash = "sha256:WIN"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
})
it('reads entries from a CRLF-terminated config', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:CRLF"',
''
].join('\r\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(key)?.trustedHash).toBe('sha256:CRLF')
expect(result.get(key)?.enabled).toBe(true)
})
it('keeps blocks that have no `trusted_hash` field so callers can see enabled-only state', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [`[hooks.state."${key}"]`, 'enabled = false', ''].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(1)
expect(result.get(key)).toEqual({ trustedHash: undefined, enabled: false })
})
it('reads disabled state alongside a valid trusted hash', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
`[hooks.state."${key}"]`,
'enabled = false',
'trusted_hash = "sha256:DISABLED"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.get(key)).toEqual({ trustedHash: 'sha256:DISABLED', enabled: false })
})
it('does not extract a fake [hooks.state."<key>"] header from inside a """ block', () => {
// Why: a header-shaped line embedded in a multi-line basic string must not
// be parsed as a real trust entry.
const original = [
'description = """',
'[hooks.state."fake-key"]',
'enabled = true',
'trusted_hash = "sha256:FAKE"',
'"""',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(0)
})
it("does not extract a fake [hooks.state.\"<key>\"] header from inside a ''' block", () => {
// Why: same false-positive guard for multi-line literal strings.
const original = [
"description = '''",
'[hooks.state."fake-key"]',
'enabled = true',
'trusted_hash = "sha256:FAKE"',
"'''",
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(0)
})
it('reads a block whose header has an inline comment', () => {
// Why: regression — headerLineRegex used to reject TOML-valid trailing
// comments, hiding existing trust entries from getStatus and causing
// it to misreport hooks as untrusted.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:CMT"\n`
writeFileSync(configPath, original, 'utf-8')
const result = readHookTrustEntries(configPath)
expect(result.size).toBe(1)
expect(result.get(key)?.trustedHash).toBe('sha256:CMT')
})
})
describe('parseTrustKey', () => {
it('parses a typical posix-style key', () => {
expect(parseTrustKey('/Users/x/.codex/hooks.json:pre_tool_use:0:0')).toEqual({
sourcePath: '/Users/x/.codex/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0
})
})
it('parses a Windows-style sourcePath whose drive letter contains a colon', () => {
// Why: validates the "anchor on the LAST three colons" approach so colons
// inside the sourcePath itself round-trip correctly.
expect(parseTrustKey('C:\\Users\\x\\.codex\\hooks.json:session_start:2:3')).toEqual({
sourcePath: 'C:\\Users\\x\\.codex\\hooks.json',
eventLabel: 'session_start',
groupIndex: 2,
handlerIndex: 3
})
})
it('returns null for a non-Codex event label', () => {
expect(parseTrustKey('/x/hooks.json:not_an_event:0:0')).toBeNull()
})
it('returns null for a key with too few colons', () => {
expect(parseTrustKey('foo:bar')).toBeNull()
expect(parseTrustKey('foo')).toBeNull()
})
it('returns null when the group index is not an integer', () => {
expect(parseTrustKey('/x/hooks.json:pre_tool_use:abc:0')).toBeNull()
})
it('returns null when the handler index is not an integer', () => {
expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:abc')).toBeNull()
})
it('returns null when the source path is empty', () => {
expect(parseTrustKey(':pre_tool_use:0:0')).toBeNull()
})
it('round-trips with computeTrustKey', () => {
const entry: CodexTrustEntry = {
sourcePath: '/Users/x/.codex/hooks.json',
eventLabel: 'post_tool_use',
groupIndex: 4,
handlerIndex: 7,
command: 'irrelevant'
}
const parsed = parseTrustKey(computeTrustKey(entry))
expect(parsed).toEqual({
sourcePath: entry.sourcePath,
eventLabel: entry.eventLabel,
groupIndex: entry.groupIndex,
handlerIndex: entry.handlerIndex
})
})
// Why: Number('') === 0 silently passes Number.isInteger; without strict
// canonical-form validation, malformed keys would coerce into valid ones.
it('returns null for empty group/handler segments', () => {
expect(parseTrustKey('/x/hooks.json:pre_tool_use::0')).toBeNull()
expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:')).toBeNull()
expect(parseTrustKey('/x/hooks.json:pre_tool_use::')).toBeNull()
})
it('returns null for exponent or whitespace numeric segments', () => {
expect(parseTrustKey('/x/hooks.json:pre_tool_use:1e2:0')).toBeNull()
expect(parseTrustKey('/x/hooks.json:pre_tool_use: 0:0')).toBeNull()
expect(parseTrustKey('/x/hooks.json:pre_tool_use:01:0')).toBeNull()
})
})
describe('upsertHookTrustEntries with array-of-tables boundaries', () => {
// Why: findNextTableHeader must treat `[[array.of.tables]]` as a block
// boundary; otherwise an upsert/remove can consume past array entries
// into unrelated user content.
it('stops the replacement at a following [[array.of.tables]] header', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
`[hooks.state."${key}"]`,
'enabled = true',
'trusted_hash = "sha256:STALE"',
'',
'[[products]]',
'name = "thing"',
''
].join('\n')
writeFileSync(configPath, original, 'utf-8')
upsertHookTrustEntries(configPath, [
{
sourcePath: '/x/hooks.json',
eventLabel: 'pre_tool_use',
groupIndex: 0,
handlerIndex: 0,
command: 'echo'
}
])
const written = readFileSync(configPath, 'utf-8')
expect(written).not.toContain('STALE')
expect(written).toContain('[[products]]')
expect(written).toContain('name = "thing"')
})
})
+577
View File
@@ -0,0 +1,577 @@
/* eslint-disable max-lines -- Why: Codex hook trust parsing, hashing, and byte-preserving TOML edits share one fragile file-format contract; splitting would make the compatibility shim harder to audit. */
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'fs'
import { dirname, join } from 'path'
import { createHash, randomUUID } from 'crypto'
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
// the "review required" pile and never fires, so the agent-status sidebar
// silently goes blank. We reproduce Codex's hash so install() can register
// trust the same way `/hooks` would. Algorithm reverse-engineered from
// codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) +
// codex-rs/config/src/fingerprint.rs (version_for_toml).
export type CodexEventLabel =
| 'pre_tool_use'
| 'permission_request'
| 'post_tool_use'
| 'pre_compact'
| 'post_compact'
| 'session_start'
| 'user_prompt_submit'
| 'stop'
export type CodexTrustEntry = {
/** Path on disk to the hooks.json that declares the hook (the "key_source"). */
sourcePath: string
/** Codex event label (snake_case). */
eventLabel: CodexEventLabel
/** 0-based index of the matcher group within the event array. */
groupIndex: number
/** 0-based index of the handler within the matcher group's `hooks` array. */
handlerIndex: number
/** The exact `command` string written to hooks.json. */
command: string
/** Effective timeout in seconds. When undefined, defaults to 600.
* Explicit values are clamped to a minimum of 1. */
timeoutSec?: number
/** Whether the handler is async. Defaults to false. */
async?: boolean
/** Optional matcher pattern (only meaningful for events that support it). */
matcher?: string
/** Optional statusMessage field. */
statusMessage?: string
}
export type CodexHookTrustState = {
trustedHash?: string
enabled?: boolean
}
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize)
}
if (value && typeof value === 'object') {
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = canonicalize((value as Record<string, unknown>)[key])
}
return sorted
}
return value
}
// Why: reproduces command_hook_hash. NormalizedHookIdentity has `group:
// MatcherGroup` flattened in, so the wire shape is { event_name, matcher?,
// hooks: [<normalized handler>] }. `matcher` is omitted (not null) when
// absent — Rust's Option<String>=None drops through the TOML→JSON path.
// Handler is normalized to timeout=600 (or explicit, min 1) and async=false.
export function computeTrustedHash(entry: CodexTrustEntry): string {
const handler: Record<string, unknown> = {
type: 'command',
command: entry.command,
timeout: Math.max(1, entry.timeoutSec ?? 600),
async: entry.async ?? false
}
if (entry.statusMessage !== undefined) {
handler.statusMessage = entry.statusMessage
}
const identity: Record<string, unknown> = {
event_name: entry.eventLabel,
hooks: [handler]
}
if (entry.matcher !== undefined) {
identity.matcher = entry.matcher
}
const serialized = JSON.stringify(canonicalize(identity))
return `sha256:${createHash('sha256').update(serialized).digest('hex')}`
}
export function computeTrustKey(entry: CodexTrustEntry): string {
return `${entry.sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}`
}
export function parseTrustKey(key: string): {
sourcePath: string
eventLabel: CodexEventLabel
groupIndex: number
handlerIndex: number
} | null {
// Why: keys have shape `<sourcePath>:<eventLabel>:<groupIdx>:<handlerIdx>`.
// sourcePath itself may contain `:` (Windows drive letters), so anchor the
// parse at the LAST three colons rather than the first.
const lastColon = key.lastIndexOf(':')
if (lastColon === -1) {
return null
}
const handlerStr = key.slice(lastColon + 1)
if (!isCanonicalNonNegativeInt(handlerStr)) {
return null
}
const secondLast = key.lastIndexOf(':', lastColon - 1)
if (secondLast === -1) {
return null
}
const groupStr = key.slice(secondLast + 1, lastColon)
if (!isCanonicalNonNegativeInt(groupStr)) {
return null
}
const thirdLast = key.lastIndexOf(':', secondLast - 1)
if (thirdLast === -1) {
return null
}
const eventLabel = key.slice(thirdLast + 1, secondLast)
if (!isCodexEventLabel(eventLabel)) {
return null
}
const sourcePath = key.slice(0, thirdLast)
if (sourcePath.length === 0) {
return null
}
return {
sourcePath,
eventLabel,
groupIndex: Number(groupStr),
handlerIndex: Number(handlerStr)
}
}
// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger,
// so reject any non-canonical decimal form before numeric conversion.
function isCanonicalNonNegativeInt(value: string): boolean {
return /^(0|[1-9]\d*)$/.test(value)
}
function isCodexEventLabel(value: string): value is CodexEventLabel {
return (
value === 'pre_tool_use' ||
value === 'permission_request' ||
value === 'post_tool_use' ||
value === 'pre_compact' ||
value === 'post_compact' ||
value === 'session_start' ||
value === 'user_prompt_submit' ||
value === 'stop'
)
}
// Why: TOML 1.0 forbids BOMs but real-world editors (especially on Windows) sometimes
// write them. A leading  would break header regexes anchored at `^[ \t]*\[`, so
// strip it once at the file boundary and let the rest of the parser stay simple.
function readTomlFile(configPath: string): string {
const raw = readFileSync(configPath, 'utf-8')
return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
}
// Why: regex-edit ~/.codex/config.toml rather than parse + reserialize. The
// file is hand-edited by users (and other tools) and a round-trip through
// any TOML library would lose comments, key ordering, and inline-table
// style. We only ever (a) replace an existing [hooks.state."<key>"] block
// keyed by *our* known hook keys, or (b) append a new block at EOF. Other
// content is byte-preserved.
// Why: this is a read-modify-write with no inter-process lock. Codex CLI's
// /hooks flow also writes [hooks.state.*] blocks in this file, so two
// concurrent writers can lose each other's edits. writeConfigAtomically
// prevents partial writes but not lost updates. install() is idempotent
// (deterministic hashes), so the next install() repairs drift.
export function upsertHookTrustEntries(
configPath: string,
entries: readonly CodexTrustEntry[]
): void {
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
let updated = existing
for (const entry of entries) {
updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
}
if (updated === existing) {
return
}
writeConfigAtomically(configPath, updated)
}
// Why: build the canonical block we own. The two field names mirror what
// Codex itself writes when the user approves via /hooks (HookStateToml
// fields). `enabled` is plumbed through so an existing user-set
// `enabled = false` survives reinstall.
function buildTrustBlock(key: string, hash: string, enabled: boolean): string {
return [
`[hooks.state."${escapeTomlString(key)}"]`,
`enabled = ${enabled}`,
`trusted_hash = "${escapeTomlString(hash)}"`
].join('\n')
}
// Why: TOML basic strings forbid raw control chars; escape backslash first so
// later substitutions don't double-escape the inserted backslashes.
function escapeTomlString(value: string): string {
return value
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('\b', '\\b')
.replaceAll('\f', '\\f')
.replaceAll('\n', '\\n')
.replaceAll('\r', '\\r')
.replaceAll('\t', '\\t')
}
function upsertTrustBlock(content: string, key: string, hash: string): string {
const headerPattern = buildHeaderPattern(key)
const match = headerPattern.exec(content)
if (!match) {
const block = buildTrustBlock(key, hash, true)
if (content.length === 0) {
return `${block}\n`
}
// Why: leave one blank line before our appended block so the file stays
// readable, but don't compound separators when the file already ends in
// a blank line.
const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n'
return `${content}${separator}${block}\n`
}
const headerStart = match.index + (match[1] ? match[1].length : 0)
const headerLineEnd = match.index + match[0].length
// Why: find the next top-level table header [...] so we replace ONLY this
// block. Comments and blank lines between us and the next header are part
// of our block and get rewritten — Codex itself only writes the two known
// fields, so this is safe.
const after = content.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
const blockEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel
// Why: preserve a user-set `enabled = false` so a hand-disabled hook is not
// silently re-enabled by the next auto-install on app start.
const existingBlock = content.slice(headerLineEnd, blockEnd)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(
existingBlock
)
const enabled = enabledMatch ? enabledMatch[1] === 'true' : true
const block = buildTrustBlock(key, hash, enabled)
return `${content.slice(0, headerStart)}${block}\n${content.slice(blockEnd)}`
}
// Why: Codex emits the canonical form with the key double-quoted; we never
// share this slot with another tool, so we don't bother accepting bare
// dotted-key variants.
// Why: accept both LF and CRLF — Windows editors (and some user-edited files)
// terminate the header line with \r\n.
// Why: TOML allows leading whitespace before headers, so accept indented
// headers — the reader does too, and a column-0-only writer would otherwise
// append a duplicate block on hand-indented configs.
// Why: trailing newline is a lookahead so a header at EOS without a final
// newline still matches (otherwise we append a duplicate block).
// Why: TOML allows `# inline comment` after `]`, so accept it before the
// line-end lookahead — otherwise a user-annotated header would force the
// no-match branch and append a duplicate block.
function buildHeaderPattern(key: string): RegExp {
const escapedKey = escapeRegex(escapeTomlString(key))
return new RegExp(
`(^|\\r?\\n)[ \\t]*\\[hooks\\.state\\."${escapedKey}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
)
}
function escapeRegex(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines
// inside multi-line strings aren't headers, so we need a stateful scanner —
// a flat regex misclassifies both cases.
function findNextTableHeader(text: string): number {
let cursor = 0
let inMultilineBasic = false
let inMultilineLiteral = false
while (cursor < text.length) {
const newlineIdx = text.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? text.length : newlineIdx
const rawLine = text.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
if (inMultilineBasic) {
if (countUnescapedTripleQuote(line, '"""') % 2 === 1) {
inMultilineBasic = false
}
} else if (inMultilineLiteral) {
if (countTripleQuote(line, "'''") % 2 === 1) {
inMultilineLiteral = false
}
} else {
const trimmed = line.trimStart()
// Why: stop at both `[table]` and `[[array.of.tables]]` — both end our
// block. Skipping `[[ ]]` here would let our slice consume past array
// entries into unrelated user content.
if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) {
return cursor
}
// Odd count means this line opens a multi-line string without closing it.
if (countUnescapedTripleQuote(line, '"""') % 2 === 1) {
inMultilineBasic = true
}
if (countTripleQuote(line, "'''") % 2 === 1) {
inMultilineLiteral = true
}
}
if (newlineIdx === -1) {
return -1
}
cursor = newlineIdx + 1
}
return -1
}
// Why: literal strings (`'''`) don't honor escapes, so a plain indexOf scan
// suffices.
function countTripleQuote(line: string, quote: string): number {
let count = 0
let i = 0
while ((i = line.indexOf(quote, i)) !== -1) {
count++
i += 3
}
return count
}
// Why: basic multi-line strings honor `\"` (and `\\`) escapes. Skip past
// `\<char>` so an escaped quote doesn't count toward triple-quote scans.
function countUnescapedTripleQuote(line: string, quote: '"""'): number {
let count = 0
let i = 0
while (i < line.length) {
if (line[i] === '\\' && i + 1 < line.length) {
i += 2
continue
}
if (line.startsWith(quote, i)) {
count++
i += 3
continue
}
i++
}
return count
}
// Why: walk the header byte-by-byte so `]` inside a quoted key segment
// doesn't terminate us early. Basic strings honor `\` escapes; literal
// strings (single quotes) don't allow escapes per TOML spec.
// Accepts both `[table]` and `[[array.of.tables]]` since either ends a block.
function isCompleteTableHeader(line: string): boolean {
if (!line.startsWith('[')) {
return false
}
const isArrayHeader = line.startsWith('[[')
let i = isArrayHeader ? 2 : 1
let inBasicQuote = false
let inLiteralQuote = false
while (i < line.length) {
const ch = line[i]
if (inBasicQuote) {
if (ch === '\\' && i + 1 < line.length) {
i += 2
continue
}
if (ch === '"') {
inBasicQuote = false
}
i++
continue
}
if (inLiteralQuote) {
if (ch === "'") {
inLiteralQuote = false
}
i++
continue
}
if (ch === '"') {
inBasicQuote = true
i++
continue
}
if (ch === "'") {
inLiteralQuote = true
i++
continue
}
if (ch === ']') {
if (isArrayHeader) {
if (line[i + 1] !== ']') {
return false
}
const tail = line.slice(i + 2)
return /^\s*(#.*)?$/.test(tail)
}
const tail = line.slice(i + 1)
return /^\s*(#.*)?$/.test(tail)
}
i++
}
return false
}
// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a
// half-written config.toml can brick a user's Codex install, so write to
// tmp and rename. Random-suffix tmp name avoids cross-process races on
// rapid reinstalls.
function writeConfigAtomically(configPath: string, contents: string): void {
const dir = dirname(configPath)
mkdirSync(dir, { recursive: true })
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
let renamed = false
try {
writeFileSync(tmpPath, contents, 'utf-8')
if (existsSync(configPath)) {
copyFileSync(configPath, `${configPath}.bak`)
}
renameSync(tmpPath, configPath)
renamed = true
} finally {
if (!renamed && existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// best effort — surfacing the cleanup failure would mask the original write error
}
}
}
}
export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void {
if (!existsSync(configPath)) {
return
}
const existing = readTomlFile(configPath)
let updated = existing
for (const key of keys) {
updated = removeTrustBlock(updated, key)
}
if (updated === existing) {
return
}
writeConfigAtomically(configPath, updated)
}
function removeTrustBlock(content: string, key: string): string {
const headerPattern = buildHeaderPattern(key)
const match = headerPattern.exec(content)
if (!match) {
return content
}
// Why: skip past the captured leading newline so we don't fuse the previous
// line into the next header (e.g. `a = 1[other]` — invalid TOML).
const cutStart = match.index + (match[1] ? match[1].length : 0)
const headerLineEnd = match.index + match[0].length
const after = content.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
const cutEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel
return content.slice(0, cutStart) + content.slice(cutEnd)
}
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
const result = new Map<string, CodexHookTrustState>()
if (!existsSync(configPath)) {
return result
}
const content = readTomlFile(configPath)
// Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or
// `'''...'''` multi-line string isn't mistaken for a real header.
// Why: accept an optional `# inline comment` after `]` — TOML permits it,
// and rejecting hides a real entry, making getStatus misreport trustMissing.
const headerLineRegex = /^[ \t]*\[hooks\.state\."((?:[^"\\]|\\.)*)"\][ \t]*(?:#[^\r\n]*)?$/
let cursor = 0
let inMultilineBasic = false
let inMultilineLiteral = false
while (cursor < content.length) {
const newlineIdx = content.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
const rawLine = content.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
if (inMultilineBasic) {
if (countUnescapedTripleQuote(line, '"""') % 2 === 1) {
inMultilineBasic = false
}
cursor = nextCursor
continue
}
if (inMultilineLiteral) {
if (countTripleQuote(line, "'''") % 2 === 1) {
inMultilineLiteral = false
}
cursor = nextCursor
continue
}
const headerMatch = headerLineRegex.exec(line)
if (headerMatch) {
const escapedKey = headerMatch[1]
const key = unescapeTomlString(escapedKey)
// Why: block ends at the next *real* header (multi-line aware).
const after = content.slice(nextCursor)
const nextHeaderRel = findNextTableHeader(after)
const blockEnd = nextHeaderRel === -1 ? content.length : nextCursor + nextHeaderRel
const block = content.slice(nextCursor, blockEnd)
// Why: we own this block's shape (only `enabled` + `trusted_hash`), so
// a line scan beats pulling in a full TOML value parser.
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
result.set(key, {
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined,
enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined
})
cursor = nextCursor
continue
}
if (countUnescapedTripleQuote(line, '"""') % 2 === 1) {
inMultilineBasic = true
}
if (countTripleQuote(line, "'''") % 2 === 1) {
inMultilineLiteral = true
}
cursor = nextCursor
}
return result
}
function unescapeTomlString(escaped: string): string {
let result = ''
let i = 0
while (i < escaped.length) {
const ch = escaped[i]
if (ch === '\\' && i + 1 < escaped.length) {
const next = escaped[i + 1]
if (next === 'n') {
result += '\n'
} else if (next === 'r') {
result += '\r'
} else if (next === 't') {
result += '\t'
} else if (next === 'b') {
result += '\b'
} else if (next === 'f') {
result += '\f'
} else if (next === '"') {
result += '"'
} else if (next === '\\') {
result += '\\'
}
// Why: unknown escapes round-trip — preserve the backslash so we don't
// silently drop information.
else {
result += `\\${next}`
}
i += 2
} else {
result += ch
i++
}
}
return result
}
+191 -17
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
import { homedir } from 'os'
import { join } from 'path'
import { app } from 'electron'
@@ -6,10 +7,22 @@ import {
createManagedCommandMatcher,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
computeTrustKey,
computeTrustedHash,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
upsertHookTrustEntries,
type CodexEventLabel,
type CodexHookTrustState,
type CodexTrustEntry
} from './config-toml-trust'
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
@@ -30,6 +43,22 @@ function getConfigPath(): string {
return join(homedir(), '.codex', 'hooks.json')
}
function getCodexConfigTomlPath(): string {
return join(homedir(), '.codex', 'config.toml')
}
// Why: Codex's hash key uses the snake_case event label (see
// codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the
// PascalCase serde-rename. Map between them at one place so the trust-write
// path can't drift from the install path.
const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = {
SessionStart: 'session_start',
UserPromptSubmit: 'user_prompt_submit',
PreToolUse: 'pre_tool_use',
PostToolUse: 'post_tool_use',
Stop: 'stop'
}
function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
}
@@ -39,7 +68,7 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"`
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
@@ -108,37 +137,102 @@ export class CodexHookService {
}
}
// Why: Report `partial` when only some managed events are registered so the
// sidebar surfaces a degraded install rather than a false-positive
// `installed`. Each CODEX_EVENTS entry must contain the managed command for
// the integration to function end-to-end (e.g. PreToolUse is required for
// permission-prompt detection per the comment above).
// Why: Report `partial` when managed events are missing OR when their
// trust entries are missing/stale. Codex 0.129+ silently drops untrusted
// hooks, so a green status without trust verification is misleading.
const command = getManagedCommand(scriptPath)
const tomlPath = getCodexConfigTomlPath()
// Why: an unreadable config.toml (EACCES/EIO) is distinct from "file
// absent" (which returns an empty Map without throwing). Hooks.json may
// still be fine, so report partial with a specific reason rather than
// collapsing to a generic error or masking it as universally-stale trust.
let trustEntries: Map<string, CodexHookTrustState>
let trustReadError: string | null = null
try {
trustEntries = readHookTrustEntries(tomlPath)
} catch (error) {
trustEntries = new Map()
trustReadError = error instanceof Error ? error.message : String(error)
}
const missing: string[] = []
const trustMissing: string[] = []
const disabled: string[] = []
let presentCount = 0
for (const eventName of CODEX_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
if (hasCommand) {
presentCount += 1
} else {
// Why: install() appends our managed definition at the end, so its
// group index is the LAST match. Picking the first match would
// misreport stale duplicates as trust-missing.
let foundGroupIndex = -1
let foundHandlerIndex = -1
definitions.forEach((definition, idx) => {
const hooks = definition.hooks ?? []
// Why: mirror the LAST-match-wins rule at the group level — if a user
// merged hook arrays and ended up with our command at multiple indices
// in one group, the surviving runtime entry is the last one.
const handlerIdx = hooks.findLastIndex((hook) => hook.command === command)
if (handlerIdx !== -1) {
foundGroupIndex = idx
foundHandlerIndex = handlerIdx
}
})
if (foundGroupIndex === -1) {
missing.push(eventName)
continue
}
presentCount += 1
// Why: a stale hash blocks firing the same as a missing entry, so
// compare against the canonical hash we would write.
// Why: capture the actual handler index — Codex's hook_key uses the
// positional handlerIndex, and a user-merged hook array can put our
// command at a non-zero slot, so hardcoding 0 would misreport trust.
const trustInput: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: foundGroupIndex,
handlerIndex: foundHandlerIndex,
command
}
const expectedHash = computeTrustedHash(trustInput)
const actualState = trustEntries.get(computeTrustKey(trustInput))
if (actualState?.trustedHash !== expectedHash) {
trustMissing.push(eventName)
} else if (actualState?.enabled === false) {
disabled.push(eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
if (presentCount === 0) {
state = 'not_installed'
// Why: surface the trust read error even when not_installed so the user
// has actionable info if config.toml is broken.
detail = trustReadError !== null ? `Trust entries unverifiable: ${trustReadError}` : null
} else if (
missing.length === 0 &&
trustMissing.length === 0 &&
disabled.length === 0 &&
trustReadError === null
) {
state = 'installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
const parts: string[] = []
if (missing.length > 0) {
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
}
if (trustReadError !== null) {
parts.push(`Trust entries unverifiable: ${trustReadError}`)
} else if (trustMissing.length > 0) {
parts.push(`Trust entry missing or stale for events: ${trustMissing.join(', ')}`)
}
if (disabled.length > 0) {
parts.push(`Managed hook disabled for events: ${disabled.join(', ')}`)
}
detail = parts.join('; ')
}
return { agent: 'codex', state, configPath, managedHooksPresent, detail }
}
@@ -190,6 +284,11 @@ export class CodexHookService {
}
}
// Why: Codex 0.129+ requires a per-hook trust entry in config.toml or the
// hook sits in the "review required" pile. We compute the trust hash for
// each managed entry as we install it and persist it alongside hooks.json
// so the user does not have to /hooks-approve after every install.
const trustEntries: CodexTrustEntry[] = []
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
@@ -197,11 +296,36 @@ export class CodexHookService {
hooks: [{ type: 'command', command }]
}
nextHooks[eventName] = [...cleaned, definition]
// Why: our managed definition is appended after `cleaned`, so its
// group index in the resulting hooks.json is `cleaned.length`. The
// handler is always the first (and only) entry in the group, so
// handler index is 0. Codex's hook_key uses these positional indices.
trustEntries.push({
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
command
})
}
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
// Why: trust entries write last so a half-write can't leave a hash
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
try {
upsertHookTrustEntries(getCodexConfigTomlPath(), trustEntries)
} catch (error) {
return {
agent: 'codex',
state: 'error',
configPath,
managedHooksPresent: true,
detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
return this.getStatus()
}
@@ -238,6 +362,56 @@ export class CodexHookService {
}
config.hooks = nextHooks
writeHooksJson(configPath, config)
// Why: also drop our trust entries so config.toml doesn't accumulate dead
// [hooks.state."..."] blocks across install/remove cycles. Best-effort —
// a stale entry is harmless once hooks.json no longer references it.
try {
const tomlPath = getCodexConfigTomlPath()
const existingEntries = readHookTrustEntries(tomlPath)
const scriptPath = getManagedScriptPath()
const command = getManagedCommand(scriptPath)
const managedEventLabels = new Set<CodexEventLabel>(
CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event])
)
// Why: only drop entries WE wrote. configPath (~/.codex/hooks.json) is
// shared with Codex CLI, so user-approved trust entries for non-Orca
// commands live in the same `[hooks.state.*]` namespace. Match by hash
// equivalence to our managed command — a sourcePath-only filter would
// wipe the user's manually-approved entries.
const ourKeys: string[] = []
for (const [key, state] of existingEntries) {
const parts = parseTrustKey(key)
if (parts === null) {
continue
}
if (parts.sourcePath !== configPath) {
continue
}
if (!managedEventLabels.has(parts.eventLabel)) {
continue
}
const expectedHash = computeTrustedHash({
sourcePath: configPath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command
})
if (state.trustedHash !== expectedHash) {
continue
}
ourKeys.push(key)
}
if (ourKeys.length > 0) {
removeHookTrustEntries(tomlPath, ourKeys)
}
} catch (error) {
// Best effort — stale trust entries are harmless once hooks.json no
// longer references the hook. Log so a programmer error doesn't disappear silently.
console.warn('[codex-hook-service] failed to clean trust entries', error)
}
return this.getStatus()
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
createManagedCommandMatcher,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
@@ -51,7 +52,7 @@ function getManagedScriptPath(): string {
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"`
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(): string {
+21
View File
@@ -156,6 +156,27 @@ describe('DaemonClient', () => {
'Something went wrong'
)
})
it('adds recovery hints to node-pty daemon diagnostics', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
const req = msg as { id: string; type: string }
return encodeNdjson({
id: req.id,
ok: false,
error:
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
})
}
})
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await expect(client.request('listSessions', undefined)).rejects.toThrow(
"Daemon's node-pty install is gone (worktree deleted?). Restart Orca. node-pty: posix_spawn failed: ENOENT"
)
})
})
describe('events', () => {
+5 -2
View File
@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
const CONNECT_TIMEOUT_MS = 5000
const REQUEST_TIMEOUT_MS = 30000
@@ -220,7 +221,9 @@ export class DaemonClient {
if (response.ok) {
resolve()
} else {
reject(new DaemonProtocolError(response.error ?? 'Hello rejected'))
reject(
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
)
}
} catch {
reject(new DaemonProtocolError('Invalid hello response'))
@@ -248,7 +251,7 @@ export class DaemonClient {
if (response.ok) {
pending.resolve(response.payload)
} else {
pending.reject(new DaemonProtocolError(response.error))
pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error)))
}
}
}
+30 -4
View File
@@ -120,22 +120,48 @@ describe('parseDaemonPidFile', () => {
const serialized = serializeDaemonPidFile({ pid: 12345, startedAtMs: 1_700_000_000_000 })
expect(parseDaemonPidFile(serialized)).toEqual({
pid: 12345,
startedAtMs: 1_700_000_000_000
startedAtMs: 1_700_000_000_000,
entryPath: null
})
})
it('parses JSON pid files with entryPath', () => {
const serialized = serializeDaemonPidFile({
pid: 12345,
startedAtMs: 1_700_000_000_000,
entryPath: '/repo/out/main/daemon-entry.js'
})
expect(parseDaemonPidFile(serialized)).toEqual({
pid: 12345,
startedAtMs: 1_700_000_000_000,
entryPath: '/repo/out/main/daemon-entry.js'
})
})
it('accepts JSON with startedAtMs missing and returns null for it', () => {
// Why: forward-compatible with hypothetical future daemons that might write
// pid without startedAtMs (platform where getProcessStartedAtMs returns null).
expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ pid: 9999, startedAtMs: null })
expect(parseDaemonPidFile('{"pid":9999}')).toEqual({
pid: 9999,
startedAtMs: null,
entryPath: null
})
})
it('falls back to bare-integer parsing for legacy pid files', () => {
// Why: pre-Phase-0 daemons wrote the pid file as a bare integer.
// parseDaemonPidFile must still accept those to avoid leaking a stale
// daemon across a single upgrade boundary.
expect(parseDaemonPidFile('12345')).toEqual({ pid: 12345, startedAtMs: null })
expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null })
expect(parseDaemonPidFile('12345')).toEqual({
pid: 12345,
startedAtMs: null,
entryPath: null
})
expect(parseDaemonPidFile(' 12345\n')).toEqual({
pid: 12345,
startedAtMs: null,
entryPath: null
})
})
it('returns null for malformed input', () => {
+81 -3
View File
@@ -15,6 +15,7 @@ const START_TIME_TOLERANCE_MS = 1_500
type ParsedDaemonPid = {
pid: number
startedAtMs: number | null
entryPath: string | null
}
function canConnectSocket(socketPath: string): Promise<boolean> {
@@ -139,14 +140,19 @@ function commandLineMatchesDaemon(
export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
const trimmed = contents.trim()
try {
const parsed = JSON.parse(trimmed) as { pid?: unknown; startedAtMs?: unknown }
const parsed = JSON.parse(trimmed) as {
pid?: unknown
startedAtMs?: unknown
entryPath?: unknown
}
if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) {
return {
pid: parsed.pid,
startedAtMs:
typeof parsed.startedAtMs === 'number' && Number.isFinite(parsed.startedAtMs)
? parsed.startedAtMs
: null
: null,
entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null
}
}
} catch {
@@ -154,7 +160,7 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
}
const pid = Number(trimmed)
return Number.isFinite(pid) ? { pid, startedAtMs: null } : null
return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null } : null
}
function getLinuxProcessStartedAtMs(pid: number): number | null {
@@ -278,6 +284,78 @@ function isDaemonProcess(
}
}
function getDaemonCommandLine(pid: number): string | null {
if (process.platform === 'win32') {
try {
return execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
],
{
encoding: 'utf8',
timeout: 3_000
}
)
} catch {
return null
}
}
try {
return readFileSync(`/proc/${pid}/cmdline`, 'utf8')
} catch {
try {
return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
timeout: 2_000
})
} catch {
return null
}
}
}
export type DaemonLaunchIdentity = 'match' | 'mismatch' | 'unknown'
export function getDaemonLaunchIdentity(
runtimeDir: string,
socketPath: string,
tokenPath: string,
expectedEntryPath: string,
protocolVersion = PROTOCOL_VERSION
): DaemonLaunchIdentity {
let parsedPid: ParsedDaemonPid | null
try {
parsedPid = parseDaemonPidFile(
readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8')
)
} catch {
return 'unknown'
}
if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) {
return 'unknown'
}
if (parsedPid.entryPath) {
return parsedPid.entryPath === expectedEntryPath ? 'match' : 'mismatch'
}
// Why: older pid files did not persist entryPath. The command line still
// carries daemon-entry.js, so use it to stop dev worktrees from reusing a
// daemon forked from a deleted sibling checkout. If command-line probing is
// unavailable, fail open so we don't kill live sessions unnecessarily.
const commandLine = getDaemonCommandLine(parsedPid.pid)
if (!commandLine) {
return 'unknown'
}
return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch'
}
export async function killStaleDaemon(
runtimeDir: string,
socketPath: string,
+92 -2
View File
@@ -14,9 +14,12 @@ import { PROTOCOL_VERSION } from './types'
// non-daemon-init dependency is replaced by a minimal stub that records calls.
const {
getPathMock,
isPackagedMock,
probeSocketExistsMock,
netConnectMock,
forkMock,
healthCheckDaemonMock,
getDaemonLaunchIdentityMock,
killStaleDaemonMock,
getProcessStartedAtMsMock,
daemonClientMock,
@@ -27,8 +30,10 @@ const {
rebindLocalProviderListenersMock
} = vi.hoisted(() => {
const getPathMock = vi.fn(() => '/fake/userData')
const isPackagedMock = vi.fn(() => false)
const probeSocketExistsMock = vi.fn(() => false)
const forkMock = vi.fn()
const netConnectMock = vi.fn(() => {
// Why: the real probeSocket() in daemon-init connects to the socket and
// resolves true on 'connect', false on 'error'. Our launcher never runs
@@ -51,6 +56,7 @@ const {
})
const healthCheckDaemonMock = vi.fn(async () => true)
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
const killStaleDaemonMock = vi.fn(async () => true)
const getProcessStartedAtMsMock = vi.fn(() => 1_000_000)
@@ -73,9 +79,12 @@ const {
return {
getPathMock,
isPackagedMock,
probeSocketExistsMock,
netConnectMock,
forkMock,
healthCheckDaemonMock,
getDaemonLaunchIdentityMock,
killStaleDaemonMock,
getProcessStartedAtMsMock,
daemonClientMock,
@@ -120,7 +129,13 @@ type MockAdapter = {
}
vi.mock('electron', () => ({
app: { getPath: getPathMock, getAppPath: () => '/fake/app' }
app: {
get isPackaged() {
return isPackagedMock()
},
getPath: getPathMock,
getAppPath: () => '/fake/app'
}
}))
vi.mock('fs', () => ({
@@ -130,11 +145,12 @@ vi.mock('fs', () => ({
writeFileSync: vi.fn()
}))
vi.mock('child_process', () => ({ fork: vi.fn() }))
vi.mock('child_process', () => ({ fork: forkMock }))
vi.mock('net', () => ({ connect: netConnectMock }))
vi.mock('./daemon-health', () => ({
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
healthCheckDaemon: healthCheckDaemonMock,
killStaleDaemon: killStaleDaemonMock,
getProcessStartedAtMs: getProcessStartedAtMsMock
@@ -224,7 +240,11 @@ async function importFresh() {
unbindLocalProviderListenersMock.mockClear()
rebindLocalProviderListenersMock.mockClear()
healthCheckDaemonMock.mockClear()
getDaemonLaunchIdentityMock.mockClear()
killStaleDaemonMock.mockClear()
forkMock.mockReset()
isPackagedMock.mockReset()
isPackagedMock.mockReturnValue(false)
daemonClientMock.mockClear()
probeSocketExistsMock.mockClear()
// Why: importing daemon-init *after* resetModules means the module-level
@@ -600,4 +620,74 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'restartDaemon called before initDaemonPtyProvider'
)
})
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
return {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready' }))
}
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}
})
await launcher('/fake/socket', '/fake/token')
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token',
'/fake/app/out/main/daemon-entry.js'
)
expect(killStaleDaemonMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token'
)
expect(forkMock).toHaveBeenCalledWith(
'/fake/app/out/main/daemon-entry.js',
['--socket', '/fake/socket', '--token', '/fake/token'],
expect.objectContaining({ detached: true })
)
})
it('keeps packaged healthy-daemon reuse independent of dev app-path identity', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
getDaemonLaunchIdentityMock.mockClear()
killStaleDaemonMock.mockClear()
forkMock.mockClear()
isPackagedMock.mockReturnValue(true)
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
await launcher('/fake/socket', '/fake/token')
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
})
})
+26 -8
View File
@@ -27,7 +27,12 @@ import {
PROTOCOL_VERSION,
type ListSessionsResult
} from './types'
import { getProcessStartedAtMs, healthCheckDaemon, killStaleDaemon } from './daemon-health'
import {
getDaemonLaunchIdentity,
getProcessStartedAtMs,
healthCheckDaemon,
killStaleDaemon
} from './daemon-health'
import {
setLocalPtyProvider,
unbindLocalProviderListeners,
@@ -91,13 +96,26 @@ function probeSocket(socketPath: string): Promise<boolean> {
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return async (socketPath, tokenPath) => {
const entryPath = getDaemonEntryPath()
const healthy = await healthCheckDaemon(socketPath, tokenPath)
if (healthy) {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return {
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
// Why: dev worktrees share the same orca-dev userData, so a daemon from
// a deleted sibling checkout can pass protocol health checks while still
// pointing at missing native modules. Packaged app paths are stable and
// should preserve existing warm daemon reuse semantics.
const identity = app.isPackaged
? 'match'
: getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
if (identity === 'mismatch') {
console.warn('[daemon] Replacing daemon launched from a different app path')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return {
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
}
}
}
}
@@ -106,7 +124,6 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// before respawn so the new daemon does not race the stale process.
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
const entryPath = getDaemonEntryPath()
const userDataPath = app.getPath('userData')
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Why: detached + unref lets the daemon outlive the Electron process.
@@ -156,7 +173,8 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
getDaemonPidPath(runtimeDir),
serializeDaemonPidFile({
pid: child.pid,
startedAtMs: getProcessStartedAtMs(child.pid)
startedAtMs: getProcessStartedAtMs(child.pid),
entryPath
}),
{ mode: 0o600 }
)
+36 -16
View File
@@ -129,7 +129,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
describe('shutdown', () => {
it('kills the session', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
await adapter.shutdown(id, false)
await adapter.shutdown(id, { immediate: false })
expect(lastSubprocess.kill).toHaveBeenCalled()
})
})
@@ -312,7 +312,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
it('prevents spawn after shutdown for same sessionId', async () => {
const sessionId = 'tombstone-test'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
await adapter.shutdown(sessionId, true)
await adapter.shutdown(sessionId, { immediate: true })
await expect(adapter.spawn({ cols: 80, rows: 24, sessionId })).rejects.toThrow(
'was explicitly killed'
@@ -321,7 +321,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
it('allows spawn for different sessionId after shutdown', async () => {
await adapter.spawn({ cols: 80, rows: 24, sessionId: 'kill-me' })
await adapter.shutdown('kill-me', true)
await adapter.shutdown('kill-me', { immediate: true })
const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-one' })
expect(result.id).toBe('fresh-one')
@@ -330,7 +330,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
it('clearTombstone allows re-spawn', async () => {
const sessionId = 'cleared-tombstone'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
await adapter.shutdown(sessionId, true)
await adapter.shutdown(sessionId, { immediate: true })
adapter.clearTombstone(sessionId)
@@ -349,7 +349,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const id = `evict-${i}`
ids.push(id)
await adapter.spawn({ cols: 80, rows: 24, sessionId: id })
await adapter.shutdown(id, true)
await adapter.shutdown(id, { immediate: true })
}
// All 5 should be tombstoned
@@ -362,7 +362,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
// clearTombstone the first one, then re-kill it — it should still work
adapter.clearTombstone(ids[0])
await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })
await adapter.shutdown(ids[0], true)
await adapter.shutdown(ids[0], { immediate: true })
// First tombstone was re-added at the end of the Map, so eviction
// order is now [evict-1, evict-2, evict-3, evict-4, evict-0]
@@ -374,28 +374,32 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
describe('reconcileOnStartup', () => {
it('returns alive sessions for valid worktrees', async () => {
await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-active' })
const wt = 'repo-a::/wt/active'
await adapter.spawn({ cols: 80, rows: 24, worktreeId: wt })
const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-active']))
const { alive, killed } = await adapter.reconcileOnStartup(new Set([wt]))
expect(alive).toHaveLength(1)
expect(alive[0]).toContain('wt-active')
expect(alive[0]).toContain(wt)
expect(killed).toHaveLength(0)
})
it('kills sessions for removed worktrees', async () => {
await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-removed' })
const wt = 'repo-a::/wt/removed'
await adapter.spawn({ cols: 80, rows: 24, worktreeId: wt })
const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-other']))
const { alive, killed } = await adapter.reconcileOnStartup(new Set(['repo-a::/wt/other']))
expect(alive).toHaveLength(0)
expect(killed).toHaveLength(1)
expect(killed[0]).toContain('wt-removed')
expect(killed[0]).toContain(wt)
})
it('handles mix of valid and orphaned sessions', async () => {
await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-keep' })
await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-delete' })
const keep = 'repo-a::/wt/keep'
const drop = 'repo-a::/wt/delete'
await adapter.spawn({ cols: 80, rows: 24, worktreeId: keep })
await adapter.spawn({ cols: 80, rows: 24, worktreeId: drop })
const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-keep']))
const { alive, killed } = await adapter.reconcileOnStartup(new Set([keep]))
expect(alive).toHaveLength(1)
expect(killed).toHaveLength(1)
})
@@ -408,6 +412,22 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(alive).toHaveLength(1)
expect(killed).toHaveLength(0)
})
it('kills sessions whose id does not match the minted format, even if id is in valid set', async () => {
// Why: parsePtySessionId rejects bare UUIDs (no `@@`) and ids without
// the `::` worktree shape. Such sessions can't be attributed to any
// current worktree and must be treated as orphans regardless of
// valid-set membership. Passing the bare-uuid as a member of
// validWorktreeIds proves the new strict parser short-circuits the
// membership check — under the old loose parser this session would
// have been kept.
const sessionId = 'bare-uuid-no-separators'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
const { alive, killed } = await adapter.reconcileOnStartup(new Set([sessionId]))
expect(alive).toHaveLength(0)
expect(killed).toHaveLength(1)
})
})
describe('dispose', () => {
@@ -488,7 +508,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
await historyAdapter.shutdown(id, true)
await historyAdapter.shutdown(id, { immediate: true })
await new Promise((r) => setTimeout(r, 50))
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false)
+23 -20
View File
@@ -6,7 +6,7 @@ import { existsSync } from 'fs'
import { DaemonClient } from './client'
import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import { mintPtySessionId } from './pty-session-id'
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import {
PROTOCOL_VERSION,
@@ -230,26 +230,31 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, _immediate: boolean): Promise<void> {
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id })
this.activeSessionIds.delete(id)
this.initialCwds.delete(id)
// Why: user explicitly closed this terminal — clean up disk history
// so it doesn't trigger a false cold restore on next launch.
if (this.historyManager) {
// Why: history removal is for the "user explicitly closed this terminal"
// path. Sleep also calls shutdown but expects scrollback to survive — wake
// re-spawns and the cold-restore reader needs the dir intact. Caller
// indicates intent via opts.keepHistory.
if (this.historyManager && !opts.keepHistory) {
void this.historyManager
.removeSession(id)
.catch((err) => console.warn('[history] removeSession failed:', id, err))
}
// Why: delete-then-set ensures the entry moves to the end of Map iteration
// order, so re-killing a session doesn't leave it as the first eviction target.
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
if (this.killedSessionTombstones.size > MAX_TOMBSTONES) {
const oldest = this.killedSessionTombstones.keys().next().value
if (oldest) {
this.killedSessionTombstones.delete(oldest)
// Why: tombstone rejects reattach against a session the user explicitly
// killed. Sleep legitimately reattaches on wake, so skip both the LRU bump
// and the size-cap eviction under keepHistory.
if (!opts.keepHistory) {
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
if (this.killedSessionTombstones.size > MAX_TOMBSTONES) {
const oldest = this.killedSessionTombstones.keys().next().value
if (oldest) {
this.killedSessionTombstones.delete(oldest)
}
}
}
}
@@ -325,14 +330,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!session.isAlive) {
continue
}
// Why: session IDs use the format `${worktreeId}@@${shortUuid}`. The @@
// separator is unambiguous — worktreeIds contain hyphens and colons but
// never @@.
const separatorIdx = session.sessionId.lastIndexOf('@@')
const worktreeId =
separatorIdx !== -1 ? session.sessionId.slice(0, separatorIdx) : session.sessionId
// Why: session IDs use the format `${worktreeId}@@${shortUuid}`. Sessions
// whose id does not match the minted format (worktreeId === null) cannot
// be tied to a live worktree and are treated as orphans.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (!validWorktreeIds.has(worktreeId)) {
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
try {
await this.client.request('kill', { sessionId: session.sessionId })
} catch {
+1 -1
View File
@@ -131,7 +131,7 @@ describe('DaemonPtyProvider', () => {
describe('shutdown (kill session)', () => {
it('kills the session in the daemon', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' })
await provider.shutdown('s1', false)
await provider.shutdown('s1', { immediate: false })
expect(lastSubprocess.kill).toHaveBeenCalled()
})
+1 -1
View File
@@ -62,7 +62,7 @@ export class DaemonPtyProvider {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, _immediate: boolean): Promise<void> {
async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id })
}
+11 -3
View File
@@ -63,9 +63,17 @@ export class DaemonPtyRouter implements IPtyProvider {
this.adapterFor(id).resize(id, cols, rows)
}
async shutdown(id: string, immediate: boolean): Promise<void> {
await this.adapterFor(id).shutdown(id, immediate)
this.sessionAdapters.delete(id)
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.adapterFor(id).shutdown(id, opts)
// Why: sleep passes keepHistory=true and re-spawns against the same
// sessionId on wake. If we delete the routing entry here, adapterFor()
// falls back to `this.current` on wake — for a session that originally
// lived on a legacy adapter (different protocolVersion), the wake-side
// createOrAttach lands on the wrong adapter and creates a fresh session,
// losing the cold-restore from the legacy adapter's history dir.
if (!opts.keepHistory) {
this.sessionAdapters.delete(id)
}
}
async sendSignal(id: string, signal: string): Promise<void> {
+1
View File
@@ -10,6 +10,7 @@ export type DaemonConnectionInfo = {
export type DaemonPidFile = {
pid: number
startedAtMs: number | null
entryPath?: string
}
export type DaemonProcessHandle = {
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { addNodePtyRecoveryHint, parseNodePtyDiagnostic } from './node-pty-error-hints'
describe('node-pty diagnostic error hints', () => {
it('parses the native step and errno without dropping the original message', () => {
const message =
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/node-pty/spawn-helper'"
expect(parseNodePtyDiagnostic(message)).toEqual({ step: 'posix_spawn', errno: 2 })
expect(addNodePtyRecoveryHint(message)).toBe(
`Daemon's node-pty install is gone (worktree deleted?). Restart Orca. ${message}`
)
})
it('hints when the daemon exhausts file descriptors opening the slave pty', () => {
const message =
"node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'"
expect(addNodePtyRecoveryHint(message)).toBe(
`Daemon hit the file-descriptor limit. Restart the daemon. ${message}`
)
})
it('hints when posix_spawn reports the per-user process limit', () => {
const message =
"node-pty: posix_spawn failed: EAGAIN (errno 35, Resource temporarily unavailable) - helper='/tmp/node-pty/spawn-helper'"
expect(addNodePtyRecoveryHint(message)).toBe(
`Per-user process limit reached. Quit some agents and retry. ${message}`
)
})
it('leaves unrelated and unhinted node-pty diagnostics unchanged', () => {
expect(addNodePtyRecoveryHint('plain failure')).toBe('plain failure')
expect(
addNodePtyRecoveryHint(
"node-pty: tcsetattr failed: EIO (errno 5, Input/output error) - slave='/dev/ttys003'"
)
).toBe("node-pty: tcsetattr failed: EIO (errno 5, Input/output error) - slave='/dev/ttys003'")
})
})
+41
View File
@@ -0,0 +1,41 @@
export type NodePtyDiagnostic = {
step: string
errno: number
}
const NODE_PTY_DIAGNOSTIC_RE = /^node-pty: ([A-Za-z0-9_]+) failed: .*?\(errno (\d+)(?:, [^)]*)?\)/
export function parseNodePtyDiagnostic(message: string): NodePtyDiagnostic | null {
const match = NODE_PTY_DIAGNOSTIC_RE.exec(message)
if (!match) {
return null
}
return {
step: match[1],
errno: Number(match[2])
}
}
export function getNodePtyRecoveryHint(diagnostic: NodePtyDiagnostic): string | null {
if (diagnostic.step === 'posix_spawn' && diagnostic.errno === 2) {
return "Daemon's node-pty install is gone (worktree deleted?). Restart Orca."
}
if (diagnostic.step === 'open_slave' && diagnostic.errno === 24) {
return 'Daemon hit the file-descriptor limit. Restart the daemon.'
}
if (diagnostic.step === 'posix_spawn' && diagnostic.errno === 35) {
return 'Per-user process limit reached. Quit some agents and retry.'
}
return null
}
export function addNodePtyRecoveryHint(message: string): string {
const diagnostic = parseNodePtyDiagnostic(message)
if (!diagnostic) {
return message
}
const hint = getNodePtyRecoveryHint(diagnostic)
return hint ? `${hint} ${message}` : message
}
+92
View File
@@ -0,0 +1,92 @@
import { execFileSync } from 'child_process'
import { existsSync, renameSync } from 'fs'
import { setTimeout as delay } from 'timers/promises'
import * as pty from 'node-pty'
import { describe, expect, it } from 'vitest'
import { getNodePtySpawnHelperCandidates } from '../providers/local-pty-utils'
function currentRevokedFdCount(): number {
return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' })
.split('\n')
.filter((line) => line.includes('(revoked)')).length
}
function currentOpenFdCount(): number {
return execFileSync('lsof', ['-p', String(process.pid)], { encoding: 'utf8' })
.split('\n')
.filter((line) => line.trim().length > 0).length
}
function getExistingSpawnHelper(): string {
const helperPath = getNodePtySpawnHelperCandidates().find((candidate) => existsSync(candidate))
expect(helperPath).toBeTruthy()
return helperPath as string
}
async function spawnExitingPty(index: number): Promise<void> {
const proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(index) }
})
await new Promise<void>((resolve) => {
proc.onExit(() => resolve())
})
;(proc as unknown as { destroy?: () => void }).destroy?.()
}
const describeOnDarwin = process.platform === 'darwin' ? describe : describe.skip
describeOnDarwin('node-pty macOS spawn fd handling', () => {
it('does not leak revoked slave tty fds across exited pty spawns', async () => {
const before = currentRevokedFdCount()
for (let i = 0; i < 50; i++) {
await spawnExitingPty(i)
}
await delay(500)
const after = currentRevokedFdCount()
expect(after - before).toBe(0)
}, 15000)
it('does not leak fds when native posix_spawn setup fails', async () => {
const helperPath = getExistingSpawnHelper()
const hiddenHelperPath = `${helperPath}.orca-test-hidden`
expect(existsSync(hiddenHelperPath)).toBe(false)
const before = currentOpenFdCount()
renameSync(helperPath, hiddenHelperPath)
const restoreHelper = (): void => {
if (existsSync(hiddenHelperPath) && !existsSync(helperPath)) {
renameSync(hiddenHelperPath, helperPath)
}
}
process.on('exit', restoreHelper)
try {
for (let i = 0; i < 20; i++) {
expect(() =>
pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) }
})
).toThrow(/node-pty: posix_spawn failed: ENOENT/)
}
} finally {
restoreHelper()
process.off('exit', restoreHelper)
}
await delay(500)
const after = currentOpenFdCount()
expect(after - before).toBe(0)
}, 15000)
})

Some files were not shown because too many files have changed in this diff Show More