diff --git a/AGENTS.md b/AGENTS.md
index 2c117478e84..1fd213f742e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,7 @@
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.
-
-Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
-
-The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact:
+When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line.
```ts
// 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller:
diff --git a/README.md b/README.md
index 87ccc4995ff..98a1e21b4a1 100644
--- a/README.md
+++ b/README.md
@@ -3,16 +3,16 @@
- downloads: 6.5m
+
+ downloads: 6.7m
@@ -15,7 +15,7 @@
downloads
downloads
- 6.5m
- 6.5m
+ 6.7m
+ 6.7m
diff --git a/docs/bug-reproductions/8979-screenshots/after-fable-visible.png b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png
new file mode 100644
index 00000000000..0e8d49a8e34
Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png differ
diff --git a/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png
new file mode 100644
index 00000000000..4100383237e
Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png differ
diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md
index e5eb3c9e2d3..bac2679f86c 100644
--- a/docs/claude-scoped-oauth-usage-limits.md
+++ b/docs/claude-scoped-oauth-usage-limits.md
@@ -25,7 +25,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
-5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility.
+5. Keep malformed, unrelated, or absent entries non-fatal. Do NOT gate on `is_active`: it marks which limit is currently binding, not whether the entry's data is valid, so an `is_active: false` Fable entry with a finite `percent` must still render (#8979). Accept a missing activity flag for compatibility.
## Data Flow
@@ -40,7 +40,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
- `limits` is missing, null, malformed, or contains null entries.
- A scoped entry names another model.
- Fable percent is missing, non-numeric, or non-finite.
-- Fable is inactive and should not be rendered.
+- Fable is inactive (`is_active: false`) but still carries a finite `percent`/reset, so it must render (#8979).
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
- Both current and legacy fields exist; the current scoped entry wins.
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
@@ -50,7 +50,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
- Unit: assert scoped data wins over a legacy field.
-- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available.
+- Unit: assert malformed and unrelated scoped entries are ignored while legacy fallback remains available; assert an inactive-but-valid Fable entry still surfaces (#8979).
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
@@ -76,8 +76,8 @@ No UI implementation changes. The existing Fable row must reappear with the same
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
-- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
-- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback.
+- Failure modes covered: malformed optional data, unrelated models, inactive-but-valid limits (still rendered, #8979), missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
+- Test coverage required: current-schema success without PTY, precedence, inactive-but-valid rendering, malformed/unrelated entries, and legacy fallback.
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index 5e6638196da..e29c91a7972 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -3,16 +3,16 @@
-
+
-
+
- English · Português · 中文 · 日本語 · 한국어
+ English · 中文 · 日本語 · 한국어 · Français · Português
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
new file mode 100644
index 00000000000..a6e4e50b773
--- /dev/null
+++ b/docs/readme/README.fr.md
@@ -0,0 +1,274 @@
+
+ Orca
+
+
+
+
+
+
+
+
+
+
+
+
+ English · 中文 · 日本語 · 한국어 · Español · Português
+
+
+
+ L'orchestrateur d'IA pour les builders 100x.
+ Lancez Codex, Claude Code, OpenCode ou Pi côte à côte — chacun dans son propre worktree, le tout suivi au même endroit.
+
+
+
+
+
+ Sous Windows ? Prenez la dernière RC — elle inclut des correctifs Windows.
+
+
+
+
+
+
+## Fonctionnalités
+
+
+
+
+
+### Companion mobile
+
+Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
+
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### Worktrees parallèles
+
+Lancez un même prompt sur cinq agents, chacun dans son propre worktree git isolé — comparez les résultats et mergez le gagnant.
+
+[Docs →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### Splits de terminal
+
+Terminaux de niveau Ghostty avec rendu WebGL, splits infinis et un scrollback qui survit aux redémarrages.
+
+[Docs →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### Mode Design
+
+Cliquez sur n'importe quel élément d'UI dans une vraie fenêtre Chromium pour envoyer son HTML, son CSS et une capture recadrée directement dans le prompt de votre agent.
+
+[Docs →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear, natifs
+
+Parcourez PRs, issues et boards de projet dans l'app — ouvrez un worktree depuis n'importe quelle tâche et reviewz sans changer de contexte.
+
+[Docs →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### Worktrees SSH
+
+Faites tourner des agents sur une machine distante costaude, avec édition de fichiers, git et terminaux complets — reconnexion auto et port forwarding inclus.
+
+[Docs →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### Annoter les diffs IA
+
+Posez des commentaires sur n'importe quelle ligne de diff et renvoyez-les à l'agent — review, édition et commit sans quitter Orca.
+
+[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### Glisser-déposer vers les agents
+
+L'éditeur VS Code avec autosave partout — glissez fichiers ou images directement dans le prompt d'un agent.
+
+[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+Les agents pilotent aussi Orca — scriptez n'importe quel workflow avec `orca worktree create`, `snapshot`, `click` et `fill`.
+
+[Docs →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**Aussi dans la boîte :**
+
+- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Cherchez parmi worktrees, fichiers, agents, commandes et contexte du repo sans quitter votre flow.
+- **[Sélecteur de comptes & suivi d'usage](https://www.onorca.dev/docs/agents/usage-tracking)** — Suivez l'usage Claude et Codex et les resets de rate limit, et basculez de compte à chaud sans vous reconnecter.
+- **[Aperçus riches du repo](https://www.onorca.dev/docs/editing/markdown)** — Prévisualisez Markdown, images, PDF et docs du repo dans le workspace.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Laissez les agents piloter des apps desktop et l'UI visible quand un workflow demande une vraie interaction.
+- **[Notifications et non-lus](https://www.onorca.dev/docs/notifications)** — Sachez quand un agent termine ou a besoin d'attention, puis marquez des fils comme non lus pour y revenir plus tard.
+- **Et bien plus encore** — on ship tous les jours, donc cette liste est toujours en retard. Le [changelog](https://github.com/stablyai/orca/releases) est la vraie liste des features.
+
+---
+
+## Agents pris en charge
+
+Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, il tourne dans Orca.
+
+
+ Claude Code
+ Codex
+ Grok
+ Cursor
+ GitHub Copilot
+ OpenCode
+ MiMo Code
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Devin
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + n'importe quel agent CLI
+
+
+---
+
+## Installation
+
+### Desktop — macOS, Windows, Linux
+
+- **[Télécharger depuis onOrca.dev](https://onorca.dev/download)**
+- Ou récupérez un build directement : [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.3/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Tous les builds](https://github.com/stablyai/orca/releases/latest)
+- **Sous Windows :** utilisez la [dernière RC (`v1.4.147-rc.3`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3) — elle inclut des correctifs Windows absents de la stable.
+- Vous lancez `orca serve` sur un serveur Linux headless ? Consultez le [guide serveur Linux headless](../reference/headless-linux-server.md).
+
+_Ou via un gestionnaire de paquets :_
+
+```bash
+# macOS (Homebrew)
+brew install --cask stablyai/orca/orca
+
+# Arch Linux (AUR) — ou stably-orca-git pour compiler depuis les sources
+yay -S stably-orca-bin
+```
+
+### Companion mobile — iOS, Android
+
+Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
+
+- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
+- **Android :** [Télécharger l'APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
+
+---
+
+## Communauté & support
+
+- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
+- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
+- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième.
+
+
+
+- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
+- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
+- **Soutenez-nous :** [Mettez une star](https://github.com/stablyai/orca) sur ce repo pour suivre nos ships quotidiens.
+
+---
+
+## Développement
+
+Envie de contribuer ou de lancer le projet en local ? Consultez notre guide [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
+
+
+
+
+
+
+
+
+
+## Builds signés
+
+Signature de code Windows sponsorisée / fournie par [SignPath.io](https://signpath.io), certificat par [SignPath Foundation](https://signpath.org).
+
+## Licence
+
+Orca est libre et open source sous la [licence MIT](../../LICENSE).
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index e2516f34d12..6da07e51cd2 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -3,16 +3,16 @@
-
+
-
+
- English · Español · Português · 中文 · 한국어
+ English · 中文 · 한국어 · Español · Français · Português
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index c03089e9091..1194226915a 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -3,16 +3,16 @@
-
+
-
+
- English · Español · Português · 中文 · 日本語
+ English · 中文 · 日本語 · Español · Français · Português
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 3f61245ff8c..d0d95b38b9f 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -3,16 +3,16 @@
-
+
-
+
- English · Español · 中文 · 日本語 · 한국어
+ English · 中文 · 日本語 · 한국어 · Español · Français
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index c2323d8ebf2..595ca2461d1 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -3,16 +3,16 @@
-
+
-
+
- English · Español · Português · 日本語 · 한국어
+ English · 日本語 · 한국어 · Español · Français · Português
diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md
new file mode 100644
index 00000000000..104f6409929
--- /dev/null
+++ b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md
@@ -0,0 +1,279 @@
+# Daemon lifecycle retirement for issue #9138
+
+## Status
+
+Implemented and locally validated for PR #9277.
+
+The earlier full ownership/audit prototype is preserved at:
+
+- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot`
+- commit: `7c915909bd26670b8af36aa683cff85077395dd1`
+- GitHub:
+
+That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup
+audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future
+legacy enforcement. None of those systems ship in this PR.
+
+Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was
+in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation
+alongside v22 and older versions.
+
+## Inputs and scope decision
+
+Issue:
+
+Reviewed design comment by AmethystLiang:
+
+
+The reviewed comment correctly identifies two different problems:
+
+1. current-generation daemons have no daemon-owned empty lifecycle;
+2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act.
+
+This PR solves only the first problem. The second problem is much larger, adds steady-state
+persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement.
+Keeping it out makes the user-visible fix small enough to review and benchmark independently.
+
+The narrow implementation retains these important rules from AmethystLiang's design:
+
+- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it;
+- live sessions always win over cleanup;
+- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence;
+- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation;
+- the daemon, not app startup, makes the atomic empty decision;
+- pre-v24 daemons stay reattachable and are not automatically shut down;
+- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged.
+
+The narrow implementation changes one policy from the original comment: it does not use a blanket
+30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle
+event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime
+inactivity heuristics and periodic ownership work.
+
+## User-visible behavior
+
+```text
+Before
+
+app disconnects ──> daemon stays forever
+ ├── live sessions stay (wanted)
+ └── zero sessions also stay (leak)
+
+After
+
+clean app detach ──> daemon atomically checks itself
+ ├── any live session/work/client ──> stay alive
+ └── exactly empty ──> exit immediately
+
+unexpected drop ──> daemon atomically checks itself
+ ├── live session/work/connection ──> stay alive
+ └── exactly empty ──> exit immediately
+
+v23 and older ──> existing reattach behavior; no automatic retirement
+```
+
+An end user with live terminals should notice no change. An end user who quits with no daemon-backed
+terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket,
+live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later
+app restart launches a fresh daemon instead of reusing an empty process.
+
+## Protocol and lifecycle design
+
+### Endpoint identity
+
+Protocol v24 hello responses include:
+
+```ts
+type DaemonEndpointIdentity = {
+ pid: number
+ startedAtMs: number
+ launchNonce: string
+}
+```
+
+The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes
+the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client
+sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps
+the previous identity-free handshake.
+
+PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a
+write failure terminates the new child and fails launch instead of leaving an untracked daemon.
+
+### Clean detach
+
+At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a
+v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared
+250 ms budget. v23 and older adapters skip it.
+
+Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is
+opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still
+receive clean retirement on quit. If startup fallback has already won, the late daemon is not
+installed; it instead receives the same bounded retirement attempt, which an adopted live session
+will reject.
+
+The daemon accepts retirement only when, in one event-loop turn:
+
+- the requesting authenticated client has both control and stream sockets;
+- it is the only authenticated client;
+- every accepted transport belongs to that client;
+- no `createOrAttach` operation is in flight;
+- the terminal host has zero sessions.
+
+When all conditions hold, the daemon synchronously closes the listening server before replying. That
+is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then
+runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to
+the same event-driven empty check when the authenticated sockets close.
+
+### Initial adoption watchdog
+
+A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated
+client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the
+normal launch handoff before the parent could connect; without a bound, a parent crash during that
+handoff would orphan the new daemon forever.
+
+A complete pair permanently cancels this watchdog, and terminal admission requires that complete
+pair. Raw and partial transports pause it without extending its original deadline. It is never
+rearmed after adoption and is not a terminal inactivity or crash-reconnect timer.
+
+### Unexpected disconnect
+
+When the last client that completed both authenticated sockets loses its control connection, the
+daemon records an event-driven retirement request with no wall-clock grace.
+
+- A complete authenticated reconnect cancels the request if existing work or a transport kept the
+ daemon alive long enough to reconnect.
+- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request
+ before admission can begin.
+- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but
+ cannot erase evidence that the last fully connected app left.
+- Replacing a client ID first records the old full connection's loss; completing the replacement
+ stream cancels that evidence, while an incomplete replacement only blocks retirement.
+- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately
+ rechecks every guard and retires only if it is then exactly empty.
+
+The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the
+token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is
+not broadened into destructive or respawn authority.
+
+### Artifact cleanup
+
+The daemon removes only artifacts it can claim as its own:
+
+- token contents must match the daemon's in-memory token;
+- PID and launch nonce must match the daemon process and launch nonce;
+- cleanup first renames the canonical entry to a unique claim, validates that claim, and never
+ overwrites or unlinks a replacement installed at the canonical path.
+
+Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24
+PID/socket artifacts. Legacy cleanup behavior is unchanged.
+
+## Performance design
+
+There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or
+steady-state persistence write in this PR.
+
+The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one
+two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its
+connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection
+attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect
+bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one
+unref'ed startup watchdog, which is canceled permanently on adoption.
+
+Validation compares current main and the branch for:
+
+- daemon connect plus two-socket hello latency;
+- repeated `listSessions` RPC latency;
+- terminal echo/stream throughput through a real socket daemon;
+- clean empty-detach latency;
+- event-loop delay under repeated RPC/stream work;
+- idle CPU/RSS and timer count where observable.
+
+The acceptance target is no statistically meaningful terminal throughput regression and no new
+steady-state disk writes. Results are recorded in the PR body.
+
+### Local performance regression screen
+
+The final local host was not quiet enough for publication-grade absolute numbers: load averages were
+17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired
+same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24
+branch samples; medians across sample medians were:
+
+| Measure | `main` v23 | branch v24 |
+| -------------------- | ---------: | ---------: |
+| two-socket connect | 1.38 ms | 1.32 ms |
+| `listSessions` RPC | 0.0366 ms | 0.0374 ms |
+| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s |
+
+All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and
+event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression
+screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work
+runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY
+byte and no steady-state persistence or polling.
+
+## Verification and validation
+
+### Focused unit and integration tests
+
+- v24 requires valid matching endpoint identity on both sockets.
+- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols.
+- production launch passes PID path and nonce and writes the exact readiness identity.
+- incomplete readiness identity or failed exclusive PID publication kills and rejects the child.
+- clean empty detach exits immediately.
+- a never-used current adapter connects and retires cleanly on quit.
+- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old
+ deadline.
+- a live session, another client, raw transport, or in-flight admission rejects clean retirement.
+- a control-only overlapping client blocks but cannot erase the last full-client retirement request.
+- a same-client-ID control replacement cannot erase the prior full connection's retirement request.
+- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed
+ request.
+- startup fail-open performs bounded empty retirement without installing a late provider.
+- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later.
+- the synchronous listener fence rejects post-fence connections as retryable.
+- an unexpected empty disconnect retires immediately without a runtime inactivity timer.
+- a real reconnect cancels pending retirement while live work keeps the daemon available.
+- raw and health probes block but cannot erase pending retirement.
+- final session exit triggers an immediate guarded retirement check.
+- token/PID cleanup preserves malformed, stale, and replacement artifacts.
+- authenticated token disappearance performs one coalesced respawn; initial token absence does not.
+
+### Process/E2E tests
+
+- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts;
+- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts
+ exit;
+- prove a live session rejects retirement and remains reattachable;
+- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable;
+- never target a production runtime directory or signal a process not created by the fixture.
+
+### Repository validation
+
+- Node typecheck;
+- oxlint and repository max-lines policy;
+- focused daemon, adapter, launcher, restart, and legacy-routing suites;
+- full daemon test suite;
+- desktop and web production builds;
+- `git diff --check` and review of every changed file against `origin/main`;
+- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`;
+- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication.
+
+Final local results on macOS arm64 after the event-driven policy revision:
+
+- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped;
+- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned
+ artifacts retired immediately after its final authenticated client disconnected;
+- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed;
+- full desktop, web, and native production build passed with existing build warnings;
+- three independent post-revision review tracks covering architecture/state machines,
+ ownership/adoption, and process lifecycle ended clean after actionable findings were fixed.
+
+The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed
+successfully, and repository CI remains responsible for the supported Node/platform matrix.
+
+## Rollout and rollback
+
+This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves
+v24 as another legacy generation and does not give an older app authority to shut it down.
+
+If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated
+ownership/audit prototype remains recoverable from the archived branch and commit above; it should
+return only as a separately reviewed, benchmarked follow-up.
diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md
new file mode 100644
index 00000000000..1a0c9922a35
--- /dev/null
+++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md
@@ -0,0 +1,523 @@
+# Windows ConPTY Startup Query and Focus Authority Design
+
+Date: 2026-07-19
+
+Status: Final decision; implementation pending
+
+## Problem
+
+On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user
+text:
+
+```text
+]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\
+```
+
+An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal
+focus-in report `CSI I`.
+
+The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The
+agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only
+about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in
+addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent
+to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order
+records that cooked echo in the authoritative runtime model before any renderer-bound filtering.
+
+The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits
+DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native
+PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was
+consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol;
+the failure requires an additional agent, timing, input-mode, or replay condition.
+
+## Root Cause
+
+The startup OSC responder currently runs in Electron main only after runtime ingestion
+([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
+data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
+daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
+data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
+renderer-only filter can therefore hide the symptom without removing it from the authoritative
+model, daemon history, snapshots, or remote delivery.
+
+The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
+downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
+that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness
+without pre-approving a speculative focus workaround.
+
+## Data Flow
+
+```text
+node-pty / remote relay PTY
+ -> shell-ready marker scan
+ -> source-owned serialized ingress transaction
+ -> consume an early OSC query and write one canonical reply
+ -> match or losslessly release the measured native-Windows echo projection
+ -> authoritative emulator + persistence/history
+ -> runtime side effects and mobile/remote stream
+ -> renderer delivery or hidden-drop decision
+ -> live terminal / snapshot restore
+```
+
+Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed
+spans advance sequence and flow-control state without writing bytes into an emulator or view.
+
+## Rejected Prototype
+
+The current working-tree prototype is not the implementation of this design:
+
+- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus
+ semantics for every Windows PTY, including programs unrelated to agents.
+- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a
+ hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage.
+- Its echo state activates only after both color slots are answered. An echo of the first response
+ can escape if it arrives before the second query.
+- Its partial-match buffer can be lost on timeout, and its substring search can remove a later
+ legitimate string that happens to equal a reply.
+
+Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked
+echo filtering. Existing unrelated Windows focus-idle safeguards remain.
+
+## Existing Contracts Preserved
+
+This design extends, rather than replaces:
+
+- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query
+ authority, authoritative model restore, and raw sequence ordering;
+- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus-
+ hidden-dropped ownership and replay silence;
+- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY
+ bytes once in main before renderer delivery.
+
+The startup responder is a bounded exception needed before normal delivered/dropped ownership can be
+established. It must join the same ingestion decision, not operate as an unrelated renderer scrub.
+
+## Decision 1: Source-Owned PTY Ingress Transaction
+
+Sanitization must run on the host that owns the PTY, before that host mutates any authoritative
+model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced
+its sequence, written its emulator, recorded pending output, and broadcast the data before main's
+provider callback runs.
+
+The transaction therefore has three installations of the same shared state machine:
+
+- in `LocalPtyProvider`, before its configured runtime callback and data listeners;
+- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and
+ attached-client fanout;
+- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's
+ `SshPtyProvider` is only an RPC proxy and never sanitizes relay output.
+
+Electron main receives already-classified daemon data. A remote-runtime desktop client does not
+reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual
+provider owner but never enable the native-Windows compatibility projection.
+
+Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach`
+receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed
+color attributes. It first decides fresh versus reattach; only a fresh result constructs the
+transaction before releasing the subprocess's already-buffered early output. A reattach discards
+the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any
+PTY id can be reused.
+
+Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn`
+carries the same fresh-session intent and installs it before releasing relay PTY output; relay
+`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is
+not armed and normal query authority handles the query.
+
+Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold
+predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported
+legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a
+separate versioned spawn/data capability because daemon protocol support says nothing about relay
+support.
+
+An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots
+or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive
+the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached
+legacy session remains explicitly outside it.
+
+### Composition with shell-ready preprocessing
+
+The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold
+transport bytes and removes its private ready marker; marker bytes have never belonged to terminal
+model or delivery sequence space and continue not to count. Its released non-marker bytes enter the
+new ingress transaction in original order.
+
+Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its
+non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model,
+persistence, and views observe the resulting emissions. No later stage may introduce an unmetered
+string transform.
+
+### Raw sequence and emission contract
+
+The state machine accepts source chunks with an explicit raw half-open range and returns zero or
+more ordered emissions:
+
+```ts
+type PtyIngressSourceChunk = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+}
+
+type PtyIngressEmission = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+ transformed: boolean
+}
+```
+
+The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when
+`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without
+overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once
+from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the
+buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it.
+
+The ingress raw high-water advances when a source chunk is accepted. Model-applied and
+view-delivered high-waters advance only through ordered emissions, including empty transformed
+emissions that consume a raw range. Runtime/mobile listener metadata carries
+`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span
+cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching
+the existing `rawLength !== data.length` safety rule.
+
+This metadata is end-to-end, not local to the state machine. Implementation changes the complete
+path:
+
+1. daemon `Session` or relay `PtyHandler` emission callback;
+2. daemon/relay batching, coalescing, splitting, and wire notification;
+3. daemon adapter or `SshPtyProvider` decode;
+4. `OrcaRuntimeService` model and runtime/mobile listeners;
+5. main renderer batching, pending/drop accounting, and preload payload;
+6. renderer reconciliation and remote-runtime binary/live frame decoding.
+
+The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A
+span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing
+its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted
+only for contiguous spans and sums raw lengths independently of string lengths. A transformed
+emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush
+it as its own frame or request snapshot reconciliation instead of slicing it.
+
+If a snapshot is requested while a partial candidate is held, the transaction first abandons that
+candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and
+snapshot sequence therefore describe the same raw high-water.
+
+### Serialization and teardown
+
+Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a
+nested callback, but that callback is appended after the current source chunk rather than delivered
+ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue.
+
+On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model
+and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay
+disposal must flush both ingress prefixes and its existing pending output batches before clearing
+them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate
+state after teardown. Ordinary chunks pass through as one unchanged emission.
+
+## Decision 2: Startup OSC Queries Are Consumed Authoritatively
+
+The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup
+and the agent's short color-query timeout. Its implementation moves into the source-owned ingress
+transaction.
+
+When registered for an agent spawn, it:
+
+1. recognizes exact OSC 10/11 query grammar across provider chunks;
+2. builds replies from validated renderer-pushed foreground/background attributes;
+3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority,
+ regardless of whether the query ended with BEL or ST;
+4. records each reply transaction before writing it to the provider;
+5. consumes the answered query from the authoritative model and view emissions so neither the
+ hidden model nor a delivered renderer can answer it a second time;
+6. begins echo recognition as soon as each individual reply is written.
+
+If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
+to normal query authority. A reattach never registers startup response state, matching current
+behavior.
+
+### Exact authority transfer
+
+Startup query interception opens only for a fresh session whose atomic creation installed the
+transaction before buffered output release. It closes at the first of:
+
+- both OSC 10 and OSC 11 slots have been answered;
+- the startup deadline expires;
+- main sends an ordered authority-close control after either the consuming-view handshake or the
+ hidden-runtime ownership mark is established;
+- the spawn fails, is cancelled, reattaches, or exits.
+
+Closing query interception does not discard already-written reply candidates. Echo recognition has
+its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
+provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
+a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
+method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered
+boundary are either consumed at source or removed from emissions; queries after it pass unchanged
+to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority.
+
+The regular authority rules continue after the bounded startup window:
+
+- delivered live bytes are answered by the live view;
+- hidden-dropped live bytes are answered by the runtime model;
+- replayed, seeded, and snapshot bytes are answered by nobody;
+- the daemon's persistence emulator never writes replies.
+
+Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to
+name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a
+real third responder class, not an undocumented exception.
+
+## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model
+
+Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a
+compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do
+not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and
+capability gate.
+
+The harness records the exact projection ConPTY returns for the canonical ST reply, including its
+chunking and any console transformation. The implementation must not assume that the projection is
+always merely "remove ESC", and BEL or other reply forms are not added without separate evidence.
+For every written reply, the transaction records that exact expected projection. Recognition is:
+
+- FIFO in reply-write order;
+- anchored at the next possible output position, not an unbounded substring search;
+- active immediately for each reply instead of waiting for both color slots;
+- bounded by the startup deadline and maximum reply length;
+- streaming across chunk boundaries.
+
+If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged
+and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is
+buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A
+match advances the raw span but emits no bytes to the model, persistence, or views.
+
+Projection matching cannot prove provenance. An application can print the same projected text at
+the candidate position, causing a false-positive removal while the later real echo remains visible.
+This is the central drawback of the workaround. It is accepted only if the real provider harness
+shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise
+Orca disables the projection and keeps the visible output rather than risking deletion.
+
+When the projection is enabled, the exact-collision behavior is explicit: the first identical
+anchored candidate is removed and a later real echo is allowed through. The test fixture must assert
+that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance
+is knowable; the release gate must document the observed timing window and justify that tradeoff.
+Unknown, delayed, or interleaved transformations pass through visibly.
+
+## Decision 4: Preserve ConPTY Focus Protocol
+
+Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It
+must not remove, reorder, or fabricate transport bootstrap modes.
+
+The `[I` investigation gets a deterministic harness before a behavior change. The harness must
+exercise the actual renderer focus callback and provider write path, not only inject `CSI I`
+directly into node-pty. It records:
+
+- raw provider output and its order;
+- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004,
+ or replayed snapshot state;
+- the exact bytes written to the provider;
+- the exact bytes returned by the provider and stored in the model;
+- agent lifecycle state when the report was emitted.
+
+The implementation gate is strict:
+
+- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode
+ is not persisted as application ownership.
+- If a live agent receives transport focus before it owns terminal focus reporting, record that
+ evidence without shipping a suppression heuristic from this document.
+- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize
+ that transformation at the same pre-model ingress boundary used for OSC replies.
+
+No global `?1004h` filter ships under any outcome.
+
+If the harness proves a focus-ownership race, a follow-up design is required before implementation.
+That design must define the output-to-input ownership signal, distinguish xterm focus events from
+identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any
+snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document
+does not pre-approve an ownership state machine whose input provenance cannot yet be represented.
+
+## Snapshot and Replay Rules
+
+Interactive modes in a snapshot are capabilities of the live application, not proof that a new
+view should emit input immediately.
+
+- Cold restore into a fresh shell keeps the existing full mode reset.
+- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only
+ bootstrap ownership.
+- Snapshot serialization/replay must not mutate live ownership trackers.
+- A snapshot containing an OSC query or an old Orca reply never produces a provider write.
+- Model and renderer snapshots must both be free of matched cooked reply projections.
+
+Any future focus ownership metadata must be explicit; it cannot be inferred from serialized
+`?1004h` text.
+
+## Failure Policy
+
+Safety is asymmetric:
+
+- This design does not intentionally suppress a focus notification; evidence of a focus-ownership
+ race triggers a follow-up design instead.
+- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the
+ application.
+- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate
+ replies are forbidden.
+- Losing buffered output on timeout or teardown is forbidden.
+
+## Cross-Platform and Remote Scope
+
+- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility
+ path; focus investigation only until a proven root cause has its own complete design.
+- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround.
+- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the
+ remote host protocol later supplies explicit equivalent evidence.
+- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there;
+ desktop local main does not reinterpret its stream.
+- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query
+ response authority through the existing terminal-driver election.
+
+## Edge Cases
+
+- A query or echo split at any byte boundary, including OSC ST split across chunks.
+- The first reply echo arriving before the second color query.
+- Unrelated output, an exact application-text collision, or a partial match before the real echo.
+- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held.
+- A provider write causing a synchronous nested output callback.
+- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing
+ with output.
+- BEL-terminated queries still receiving the canonical ST reply.
+- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers.
+- Old daemon/relay protocol versions and sessions that survive an application upgrade.
+- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds.
+- Focus gained through normal terminal input versus the separate explicit reattach-focus write path.
+
+## Test Plan
+
+### Deterministic provider harness
+
+- Capture the native ConPTY bootstrap across natural and forced chunk boundaries.
+- Reproduce the OSC reply echo with the real provider and an agent query fixture.
+- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11
+ queries and combined OSC 10 `?;?`.
+- Force the first reply echo before the second query.
+- Split every byte boundary in query and echo fixtures.
+- Print the expected projection immediately before the real echo and assert that the first
+ identical candidate is removed while the later echo passes through.
+- Exercise focus gain/loss through the actual xterm focus path.
+
+### Ingress integration
+
+- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive
+ classified data before storing it.
+- Assert renderer and mobile delivery receive the same visible output.
+- Assert raw start/end spans advance by original provider length, producer ACK contribution is
+ counted exactly once, and mobile/runtime metadata reports the raw span rather than string length.
+- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move,
+ and teardown, including a prefix held from an earlier callback.
+- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the
+ write.
+- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather
+ than slicing cleaned text.
+- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam;
+ assert `SshPtyProvider` does not reinterpret relay data.
+- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection.
+- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold
+ restore; assert capable relay replay/history has the same property. Assert old daemon and relay
+ versions are detected and never represented as sanitized.
+
+### Authority and restore
+
+- Assert an early-consumed query is answered once by its source owner and never by renderer or
+ hidden model.
+- Assert normal delivered/dropped query authority resumes after startup state clears.
+- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do
+ not contain cooked OSC text.
+- Assert replay never sends OSC or focus replies.
+- Assert ordinary native console focus behavior remains enabled.
+- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any
+ later ownership implementation defines its tests in the required follow-up design.
+
+### End-to-end acceptance
+
+On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions.
+Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the
+focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops
+at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus
+behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain
+PowerShell terminal and a native focus-event consumer to prove no global regression.
+
+Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint,
+formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also
+require the repository's SSH end-to-end procedure.
+
+## UI Quality Bar
+
+This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must
+look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a
+clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and
+focus presentation.
+
+## Review Screenshots
+
+1. A fresh native-Windows agent prompt after startup, with no OSC reply text.
+2. The same session after hide/reveal restore, still clean and without duplicated output.
+3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state.
+4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior.
+
+## Rollout
+
+1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress
+ state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready
+ preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay
+ `PtyHandler` before their models, persistence/replay, and fanout.
+2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates,
+ carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query
+ authority documents with exact transfer events.
+3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical
+ ST replies, consume answered queries for model and views, and retain raw sequence accounting.
+4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO
+ anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage,
+ and authoritative daemon/model/snapshot tests.
+5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop
+ for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed
+ only with a failing regression test that selects that branch.
+6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL,
+ and SSH scenarios before removing the old startup implementation.
+
+Each slice must keep query authority singular. The compatibility projection does not ship without a
+model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness
+fails on the old behavior and passes on the new behavior.
+
+## Lightweight Eng Review
+
+- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global
+ focus filtering and an unproven ownership state machine remain out of scope.
+- Architecture/data flow: classification belongs at each PTY source owner after shell-ready
+ preprocessing and before every authoritative model, persistence, replay, or delivery consumer.
+- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes,
+ authority races, snapshot and teardown drains, protocol-version skew, reattach, and host
+ isolation.
+- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay,
+ runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and
+ renderer-focus harnesses; Electron and SSH end-to-end validation.
+- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one
+ startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery
+ path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory.
+- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage
+ disappears, without cursor/focus regressions or restore flashes.
+- Required review screenshots: the four terminal states in `Review Screenshots`.
+- Residual risks: the native projection may be too unstable to enable; exact projected application
+ output can collide; `[I` may require a separately reviewed focus-ownership design.
+
+## Non-Goals
+
+- Replacing xterm's general query parser.
+- Filtering arbitrary escape-looking terminal output.
+- Disabling ConPTY focus or Win32 input mode globally.
+- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows.
+- Solving unrelated MCP startup warnings reported beside the terminal garbage.
+
+## Final Invariants
+
+1. Provider bytes are classified once before model ingestion and view delivery.
+2. A live query has exactly one responder; replay has none.
+3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched
+ startup-reply projection.
+4. Raw provider sequence accounting survives sanitization.
+5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data.
+6. ConPTY bootstrap modes reach live emulators unchanged.
+7. No focus suppression ships from this design; a proven ownership race requires a follow-up design.
+8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state.
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index fe89a82c4a0..d5cbd0e0f1b 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -179,7 +179,7 @@ export default function RootLayout() {
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 442066ca962..126744a984e 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -108,14 +108,9 @@ function isErrorVerdict(v: ConnectionVerdict): boolean {
const REPO_METADATA_REFRESH_MS = 60_000
type HostScreenProps = {
- // Why: when true, this worktree list is rendered as the persistent tablet
- // sidebar by the host layout rather than as its own routed screen. That
- // swaps the back button for a hide-sidebar control, drives data fetching
- // from a plain mount effect (the sidebar is never the "focused" route), and
- // opens sessions into the detail pane instead of pushing a new full screen.
+ // When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
embedded?: boolean
- // Route params aren't in scope when rendered from the layout, so the caller
- // passes hostId/action explicitly; falls back to the local route params.
+ // Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
hostId?: string
action?: string
onHideSidebar?: () => void
@@ -133,16 +128,12 @@ export function HostScreen({
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
- // Why: cap and center the worktree list on wide/tablet canvases; on phones
- // isWideLayout is false so the list stays edge-to-edge as before. When
- // embedded as the sidebar the list already lives in a narrow pane, so the
- // cap is skipped (see the SectionList contentContainerStyle below).
+ // Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
- // Why: shared client per host owned by RpcClientProvider. See
- // docs/mobile-shared-client-per-host.md.
+ // 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)
@@ -156,9 +147,7 @@ export function HostScreen({
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
- // Why: opening a worktree activates it on the host, but the active-row
- // highlight otherwise waits for the next worktree.ps poll to reflect it.
- // Track the locally-opened worktree so the highlight moves instantly.
+ // Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState(null)
// One tick drives every visible agent row's relative timestamp.
const now = useNow(30_000)
@@ -180,9 +169,7 @@ export function HostScreen({
const [workspaceStatuses, setWorkspaceStatuses] = useState(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
- // displayName → repo id, populated from repo.list. The filter model keys on
- // repo ids (desktop's PersistedUIState), but the section headers/rows key on
- // displayName, so we bridge the two here.
+ // displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState>(new Map())
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
@@ -201,9 +188,7 @@ export function HostScreen({
}, [router])
const [pinnedIds, setPinnedIds] = useState>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState>(new Set())
- // Why: snapshot of the synced view settings so the focus-effect ui.get merge
- // and the optimistic ui.set writes read the latest values without forcing the
- // callbacks to re-create on every state change.
+ // Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
const viewStateRef = useRef({
groupMode: 'repo',
sortMode: 'recent',
@@ -226,8 +211,7 @@ export function HostScreen({
}
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
- // Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
- // states and the snapshot ref in one shot.
+ // Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
const applyViewState = useCallback((next: MobileViewState) => {
viewStateRef.current = next
setGroupMode(next.groupMode)
@@ -241,8 +225,7 @@ export function HostScreen({
})
}, [])
- // Optimistically apply a partial change locally, then push the full mapped
- // settings to the desktop's shared store via ui.set so both apps stay in sync.
+ // Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: Partial) => {
const next: MobileViewState = { ...viewStateRef.current, ...patch }
@@ -275,8 +258,7 @@ export function HostScreen({
}, [])
const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action)
- // Why: `action=newWorktree` is a route-derived open edge. Resolve it before
- // commit, but don't reopen after the user closes while the same URL remains.
+ // Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
if (resolvedRouteActionState !== routeActionState) {
setRouteActionState(resolvedRouteActionState)
}
@@ -285,8 +267,7 @@ export function HostScreen({
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
- // Load persisted pins from the local cache. View settings are no longer
- // stored locally — they sync from the desktop's shared store via ui.get.
+ // Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
useEffect(() => {
if (!hostId) {
return
@@ -304,9 +285,7 @@ export function HostScreen({
}
}, [hostId])
- // Read the desktop's shared view settings (PersistedUIState) and merge them
- // onto local state. Runs on connect and on screen focus so changes made on
- // desktop appear on the phone.
+ // Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(async () => {
if (!client || connState !== 'connected') {
return
@@ -328,9 +307,7 @@ export function HostScreen({
}
}, [client, connState, hostId, applyViewState])
- // 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.
+ // Why: mirror client into a ref so imperative call sites read it without re-subscribing.
useEffect(() => {
clientRef.current = client
}, [client])
@@ -342,9 +319,7 @@ export function HostScreen({
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
repoMetadataFetchedAtRef.current = 0
- // 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.
+ // Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
if (freshCache) {
setWorktrees(freshCache)
@@ -432,8 +407,7 @@ export function HostScreen({
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
- // The embedded sidebar polls for the whole split-view session; keep slow
- // remote hosts from stacking overlapping expensive list requests.
+ // Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
if (fetchWorktreesInFlightRef.current) {
return
}
@@ -442,8 +416,7 @@ export function HostScreen({
const requestHostId = hostId
try {
- // Why: worktree.ps defaults to 200 and silently truncates; match the
- // desktop's high cap so large hosts don't drop workspaces on mobile.
+ // Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces.
const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 })
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
@@ -453,9 +426,7 @@ export function HostScreen({
}
if (response.ok) {
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
- // Why: large hosts can return identical worktree.ps snapshots every
- // poll. Preserving the existing array keeps SectionList/sort rebuilds
- // off the JS tap path unless something actually changed.
+ // Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
setWorktrees((current) =>
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
@@ -463,25 +434,18 @@ export function HostScreen({
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
setWorktreesLoaded(true)
- // Why (#8498): the host detail screen seeds its list from the
- // home-written cache, so a partial home fetch could poison it until a
- // focus poll corrected it. Write the confirmed snapshot back through
- // the same cache so a reconnect refetch (or a remount) can't serve a
- // stale worktree list.
+ // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, result.worktrees)
}
- // Drop the optimistic active override once the host confirms it (the
- // activate RPC has landed and worktree.ps now reports it active), so we
- // stop overriding and respect any later desktop-driven change.
+ // Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeId((pending) =>
pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive)
? null
: pending
)
- // Clear optimistic sleep overrides once the server confirms the
- // worktree is actually inactive (liveTerminalCount dropped to 0).
+ // Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) => {
if (prev.size === 0) {
return prev
@@ -496,8 +460,7 @@ export function HostScreen({
return still.size === prev.size ? prev : still
})
- // Sync local pin state from server so desktop-initiated pins/unpins
- // are reflected without relying on stale AsyncStorage.
+ // Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(
result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId)
)
@@ -520,17 +483,10 @@ export function HostScreen({
[client, connState, hostId]
)
- // 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.
+ // Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet.
useEffect(() => {
if (connState !== 'connected' || !client) {
- // Why: drop the prior host's capabilities while disconnected/switching so
- // a capability-gated action (e.g. Agent Session History) can't linger for
- // a host that doesn't support it.
+ // Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host.
setHostCapabilities([])
return
}
@@ -556,8 +512,7 @@ export function HostScreen({
})
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.
+ // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
@@ -566,8 +521,7 @@ export function HostScreen({
})
}
} catch {
- // Why: rare path — sendRequest can throw on transport tear-down.
- // Treat as transient; verdict stays at previous value.
+ // Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict.
}
})()
return () => {
@@ -577,29 +531,22 @@ export function HostScreen({
useFocusEffect(
useCallback(() => {
- // Why: opening the host is a strong user signal — reset a backed-off or
- // trickling reconnect loop (and probe a possibly half-open socket)
- // immediately instead of waiting out its timer. Deps stay empty so this
- // fires per focus transition, not per connection-state change; nudging
- // on every reconnecting↔connecting flip would defeat the backoff.
+ // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
clientRef.current?.notifyForeground()
}, [])
)
useFocusEffect(
useCallback(() => {
- // The embedded sidebar drives its own polling below; focus never fires
- // for it since it isn't a routed screen.
+ // The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
if (embedded || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
- // Pull desktop's shared view settings on focus so desktop-side changes
- // show up here without a manual refresh.
+ // Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
void syncViewSettingsFromDesktop()
- // Why: React Navigation keeps previous stack screens mounted; only
- // poll the host list while this route is visible.
+ // Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
@@ -608,9 +555,7 @@ export function HostScreen({
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
)
- // Why: as the persistent tablet sidebar this list is never the focused
- // route, so useFocusEffect won't fetch/poll. Mirror that behavior from a
- // plain mount effect while connected instead.
+ // Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
useEffect(() => {
if (!embedded || connState !== 'connected') {
return
@@ -625,10 +570,7 @@ export function HostScreen({
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
- // Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
- // useWorktreeResync so this screen stays under its max-lines budget. The
- // steady-state focus/embedded polls don't cover the transition INTO
- // 'connected' after a background/sleep, which is when the cache is stalest.
+ // Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
@@ -720,8 +662,7 @@ export function HostScreen({
await removeHostAndCloseClient(hostId, closeHostClient)
leaveHost()
} catch {
- // Why: metadata commit can fail while the host is still paired; keep the
- // screen mounted and re-open confirm (ConfirmModal closes on confirm).
+ // Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
@@ -748,11 +689,9 @@ export function HostScreen({
const openWorktreeSession = useCallback(
(item: Worktree) => {
- // Highlight the row immediately; the next worktree.ps poll confirms it.
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
- // Why: opening a mobile session should hydrate host-owned tabs without
- // pulling other paired clients, especially desktop, into this worktree.
+ // Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
@@ -831,8 +770,7 @@ export function HostScreen({
const slept = sleptIds.has(w.worktreeId)
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
: null
- // Force the just-opened worktree active (and the rest inactive) until the
- // next poll confirms it, so the highlight doesn't lag the navigation.
+ // Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag.
const active =
optimisticActiveWorktreeId !== null
? { isActive: w.worktreeId === optimisticActiveWorktreeId }
@@ -912,12 +850,7 @@ export function HostScreen({
{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.
+ // Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
@@ -1166,8 +1099,7 @@ export function HostScreen({
onChangeText={setSearch}
placeholder="Search worktrees…"
autoFocus
- // Why: new key each open remounts focus effect if the field stays mounted
- // across rapid toggles; pairs with delayed focus so the keyboard appears.
+ // Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
focusKey={showSearch}
accessibilityLabel="Search worktrees"
/>
@@ -1206,13 +1138,10 @@ export function HostScreen({
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onScrollToIndexFailed={onScrollToIndexFailed}
- // Why: edge-to-edge — the list scrolls under the system nav bar
- // while reserving insets.bottom keeps the last worktree row reachable
- // above the Samsung 3-button nav / iOS home indicator.
+ // Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
contentContainerStyle={[
styles.list,
- // Phone shows a floating "+" button bottom-right; reserve room so the
- // last row stays tappable above it. Embedded sidebars keep the toolbar +.
+ // Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
isWideLayout &&
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
@@ -1252,8 +1181,7 @@ export function HostScreen({
)
}}
ItemSeparatorComponent={ListSeparator}
- // Why (#8498): manual pull-to-refresh forces a fresh worktree
- // snapshot after a reconnect or whenever the cache looks stale.
+ // Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
refreshControl={
void
+}
+
+export function QuickCommandsTabButton({ disabled, onPress }: Props) {
+ return (
+ <>
+
+ [
+ styles.newTerminalButton,
+ pressed && styles.newTerminalButtonPressed,
+ disabled && styles.newTerminalButtonDisabled
+ ]}
+ disabled={disabled}
+ onPress={onPress}
+ accessibilityLabel="Quick commands"
+ >
+
+
+ >
+ )
+}
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index eaa04a7436a..bd361b93c63 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -75,6 +75,12 @@ import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
+import { QuickCommandsSheet } from '../../../../src/session/QuickCommandsSheet'
+import {
+ buildMobileQuickCommandLaunch,
+ supportsMobileQuickCommands,
+ type MobileQuickCommandLaunch
+} from '../../../../src/terminal/quick-commands'
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
@@ -233,7 +239,8 @@ import {
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { styles } from './mobile-session-styles'
-import type { DiffComment } from '../../../../../src/shared/types'
+import { QuickCommandsTabButton } from './QuickCommandsTabButton'
+import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
import type {
DiffCommentActions,
DiffNotesDelivery,
@@ -277,8 +284,7 @@ function MarkdownReader({
onDiscard: () => void
keyboardLift: number
}) {
- // The editor lives in a WebView; native Keyboard events under-report its
- // covered area, so prefer the inset measured inside the WebView when larger.
+ // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
@@ -326,8 +332,7 @@ function MarkdownReader({
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
- // Why: the editor focus lives inside a WebView, so keep native
- // Save/Discard controls lifted instead of resizing that surface.
+ // Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
@@ -416,8 +421,7 @@ function DiffLineRow({
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
- // Why: review notes are anchored to the modified side, so the single mobile
- // gutter should show the same line number the note will reference.
+ // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
@@ -640,8 +644,7 @@ function FileReader({
return undefined
}
- // Why: highlighting can create many nested Text nodes; defer it one tick so
- // large files show immediately as plain text before colors are applied.
+ // Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
@@ -812,11 +815,10 @@ export default function SessionScreen() {
created?: string
warning?: string
}>()
- const isFolderWorkspaceRoute = worktreeId.startsWith('folder:')
+ const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope.
const router = useRouter()
const insets = useSafeAreaInsets()
- // Why: shared client per host owned by RpcClientProvider. See
- // docs/mobile-shared-client-per-host.md.
+ // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
@@ -827,8 +829,7 @@ export default function SessionScreen() {
routeName: routeWorktreeName,
worktreeId
})
- // Master-detail host state (U5/KTD2): on wide layouts a tapped panel docks beside the
- // session content; on narrow it stays null and the icons push full-screen routes.
+ // Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState(null)
const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0)
@@ -837,17 +838,13 @@ export default function SessionScreen() {
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
- // Why: docking needs enough measured row width. If rotation/split-screen makes
- // the session row too narrow while a panel is docked, clear activePanel so the
- // icon state and live mounted panel do not survive into overlay/push mode.
+ // Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode.
useEffect(() => {
if (!canDockPanel && activePanel !== null) {
setActivePanel(null)
}
}, [canDockPanel, activePanel])
- // Session-level GitHub remote probe gates the PR dock icon so non-GitHub
- // providers do not open the hosted-review surface. Branch/head/status for the
- // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs.
+ // GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs.
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
useMobilePrBranchContext({
client,
@@ -865,23 +862,15 @@ export default function SessionScreen() {
const terminalsRef = useRef([])
const [sessionTabs, setSessionTabs] = useState([])
const sessionTabsRef = useRef([])
- // Why: subscription, 2s polling, and post-mutation refetch race to apply tab
- // snapshots. Track the last applied (publicationEpoch, snapshotVersion) so a
- // late-arriving older snapshot from the same publisher can't overwrite (and
- // resurrect closed tabs in) a newer one. See session-tab-snapshot-gate.
+ // Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate).
const appliedSnapshotMarkerRef = useRef({ epoch: null, version: -1 })
- // Why: after an optimistic local close, suppress the tab until the publisher
- // confirms its absence, so an in-flight snapshot generated before the close
- // propagated (and thus newer by version) can't flash the tab back. Maps tab id
- // to an expiry timestamp so a failed host-side close can't hide a tab forever.
+ // Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
- // Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change
- // applies in place (the terminal panes stay mounted).
+ // Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
- // Why: local opt-in for keyboard autocomplete/autocorrect on the terminal
- // command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return.
+ // Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return.
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState('orca-browser')
@@ -897,8 +886,7 @@ export default function SessionScreen() {
const [activeHandle, setActiveHandle] = useState(null)
const [activeSessionTabId, setActiveSessionTabId] = useState(null)
const activeSessionTabIdRef = useRef(null)
- // Auto-scroll the tab strip so the active tab (synced from desktop on
- // worktree entry) is revealed without a manual scroll.
+ // Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll.
const tabStripRef = useRef(null)
const tabStripOffsetRef = useRef(0)
const tabStripViewportWidthRef = useRef(0)
@@ -913,9 +901,7 @@ export default function SessionScreen() {
const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] =
useState(null)
const [creating, setCreating] = useState(false)
- // Why: React state isn't a synchronous lock — a fast double-tap can fire two
- // creates before `creating` re-renders. This ref blocks the second one in the
- // same tick (server idempotency only dedupes identical clientMutationIds).
+ // Why: React state isn't a synchronous lock; this ref blocks a double-tap's second create in the same tick before `creating` re-renders.
const creatingTerminalRef = useRef(false)
const [creatingBrowser, setCreatingBrowser] = useState(false)
const [creatingMarkdown, setCreatingMarkdown] = useState(false)
@@ -924,6 +910,7 @@ export default function SessionScreen() {
createMobileSessionCreateWarningState(initialCreateWarning)
)
const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false)
+ const [showQuickCommands, setShowQuickCommands] = useState(false)
const [createTabAgentLoadState, setCreateTabAgentLoadState] =
useState('idle')
const [createTabAgentOptions, setCreateTabAgentOptions] = useState([])
@@ -958,14 +945,9 @@ export default function SessionScreen() {
() => getVisibleTerminalAccessoryKeys(visibleBuiltInIds),
[visibleBuiltInIds]
)
- // Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when
- // the IME opens — the keyboard draws on top of the app. We track the keyboard
- // height ourselves and translate the input/accessory area above the IME without
- // changing the terminal frame height, so keyboard open/close does not resize
- // the desktop PTY.
+ // Why: Expo SDK 55 edge-to-edge doesn't resize the window on IME open, so track keyboard height ourselves and lift the input without resizing the desktop PTY.
const [keyboardHeight, setKeyboardHeight] = useState(0)
- // Why: server-authoritative display mode per terminal. The runtime is the
- // single source of truth — this state is populated from subscribe responses.
+ // Why: server-authoritative display mode per terminal, populated from subscribe responses.
const [terminalModes, setTerminalModes] = useState>(new Map())
const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState<
Map
@@ -973,15 +955,13 @@ export default function SessionScreen() {
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
const [showDictationSetup, setShowDictationSetup] = useState(false)
- // 'hold' makes the mic press-and-hold; 'toggle' makes it tap-to-start/stop.
- // Mirrors Settings ▸ Voice ▸ Dictation Mode so the button matches the setting.
+ // 'hold' = press-and-hold mic, 'toggle' = tap-to-start/stop; mirrors Settings ▸ Voice ▸ Dictation Mode.
const [dictationMode, setDictationMode] = useState<'toggle' | 'hold'>('toggle')
const [toastMessage, setToastMessage] = useState(null)
const toastOpacityRef = useRef(new Animated.Value(0))
const toastHideTimerRef = useRef | null>(null)
const toastSeqRef = useRef(0)
- // Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
- // change so paste reads a synchronous snapshot — no round-trip required.
+ // Why: WebView pushes terminal modes on every change so paste reads a synchronous snapshot — no round-trip.
const ptyModesRef = useRef>(new Map())
const terminalGestureInputBucketsRef = useRef>(new Map())
const terminalGestureInputQueuesRef = useRef>(new Map())
@@ -989,13 +969,11 @@ export default function SessionScreen() {
const terminalCwdRef = useRef>(new Map())
const initialModesSeenRef = useRef>(new Set())
const deviceTokenRef = useRef(null)
- // Why: state (not a ref) — the connection verdict needs a re-render once
- // the endpoint loads so the Tailscale hint can appear.
+ // Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear.
const [hostEndpoint, setHostEndpoint] = useState(null)
const clientRef = useRef(null)
const connStateRef = useRef(connState)
- // Why: measured once from TerminalWebView on mount, then passed with every
- // subscribe call so the server can auto-fit the PTY to phone dimensions.
+ // Why: measured once on mount, then passed with every subscribe so the server can auto-fit the PTY to phone dims.
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef>(new Map())
@@ -1015,53 +993,31 @@ export default function SessionScreen() {
const subscribingHandlesRef = useRef>(new Set())
const initializedHandlesRef = useRef>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
- // Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
- // (opacity:0) may have delayed JS execution on iOS. We must not subscribe
- // until the WebView has fired web-ready, otherwise init() messages queue
- // and may not render reliably.
+ // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef>(new Set())
const activeHandleRef = useRef(null)
const activeSessionTabTypeRef = useRef(null)
const pendingActiveSessionTabIdRef = useRef(null)
const pendingActiveTerminalHandleRef = useRef(null)
- // Why: a browser tab opened from a terminal-tapped HTML must be focused as an
- // Orca session tab (bridge auto-activate only flags the live webContents, not
- // the app-level active tab). We remember the page id and, once its session tab
- // syncs, activate it through the normal switchSessionTab path (which also makes
- // switching back to the terminal work). A ref breaks the callback dep cycle.
+ // Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab).
const pendingBrowserFocusPageIdRef = useRef(null)
const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
const pendingTerminalActivationAttemptRef = useRef(null)
- // Why: handleTerminalOpenUrl is memoized on terminalLinkOpenMode, but
- // handleCreateBrowser is a per-render closure that captures the live `client`.
- // A terminal URL tap must run the CURRENT closure (the memoized one can hold a
- // render where client was still null/connecting, silently no-opping the
- // in-app-browser open). Route through a ref kept current every render.
+ // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null)
const initialEmptySessionAutoCreateRef = useRef(null)
const markdownSaveSeqRef = useRef>(new Map())
const markdownSaveInFlightRef = useRef>(new Set())
const subscribeSeqRef = useRef>(new Map())
- // Why: post-RPC refresh timers capture this screen and must not survive
- // route reuse or unmount.
+ // Why: post-RPC refresh timers capture this screen and must not survive route reuse or unmount.
const delayedActionTimersRef = useRef>>(new Set())
- // 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).
+ // Why: highest applyLayout seq seen per handle; drop older scrollback/resized as stale, but a >20 gap resets (fresh subscription/server restart).
const layoutSeqRef = useRef>(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,
- // which can overstate the visible area due to layout timing.
+ // Why: exact terminal-frame height for measureFitDimensions; window.innerHeight can overstate the visible area.
const terminalFrameHeightRef = useRef(0)
- // Why: the terminal frame's width changes when EITHER sidebar is resized (the
- // left worktree sidebar shrinks the detail pane; the right dock takes a slice of
- // the row) without any window-dim change. Tracking the measured width lets the
- // refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
+ // Why: sidebar resizes change the terminal frame width without a window-dim change; track it so the refit hook re-fits (see terminal-viewport-refit.ts).
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
@@ -1090,18 +1046,16 @@ export default function SessionScreen() {
activeSessionTab?.type !== 'browser'
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null)
- // Why: hosts without aiVault.v1 reject aiVault.listSessions, so the header
- // entry stays hidden there (mirrors the gated host-list action) instead of
- // opening a dead-end "update this host" panel.
+ // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState(
null
)
+ const [quickCommandsSupported, setQuickCommandsSupported] = useState(null)
// Why: stable callbacks (handleFileTap) read the live value via this ref, since
// the capability probe resolves after the callbacks are created.
const browserScreencastSupportedRef = useRef(browserScreencastSupported)
browserScreencastSupportedRef.current = browserScreencastSupported
- // Why: terminal gesture/input callbacks are intentionally stable and
- // imperative; keep their refs current before commit instead of one effect later.
+ // Why: terminal gesture/input callbacks are stable/imperative, so keep their refs current before commit, not in a later effect.
clientRef.current = client
connStateRef.current = connState
activeSessionTabTypeRef.current = activeSessionTab?.type ?? null
@@ -1112,8 +1066,7 @@ export default function SessionScreen() {
createWarningState,
initialCreateWarning
)
- // Why: Expo can reuse this screen for a new route. Reconcile before paint
- // so a dismissed old creation warning never flashes for the next session.
+ // Why: Expo can reuse this screen for a new route; reconcile before paint so a dismissed old warning doesn't flash.
if (reconciledCreateWarningState !== createWarningState) {
setCreateWarningState(reconciledCreateWarningState)
}
@@ -1205,8 +1158,7 @@ export default function SessionScreen() {
client,
enabled: canSend,
onTranscript: (text) => {
- // Why: dictation belongs to the visible composer. Native chat consumes it
- // locally; terminal mode retains the live-input routing and flush contract.
+ // Why: dictation belongs to the visible composer — native chat consumes it locally, terminal mode keeps live-input routing.
if (showNativeChatRef.current) {
nativeChatController.setChatComposerText((current) =>
appendBufferedDictation(current, text)
@@ -1214,9 +1166,7 @@ export default function SessionScreen() {
showToast('Dictation inserted')
return
}
- // Live mode inserts the transcript straight into its originating PTY as
- // text (no Return — the user sends it themselves), matching live keystroke
- // semantics; buffered mode keeps appending to the command field.
+ // Live mode inserts the transcript into its PTY as text (no Return); buffered mode appends to the command field.
const routeContext = dictationRouteContextRef.current
dictationRouteContextRef.current = null
const route = routeDictationTranscript(
@@ -1245,8 +1195,7 @@ export default function SessionScreen() {
},
onError: (err) => {
dictationRouteContextRef.current = null
- // Dictation isn't set up on the desktop yet → open the setup sheet so the
- // user can download a model + enable it from here, instead of a dead-end toast.
+ // Dictation not set up on desktop → open the setup sheet instead of a dead-end toast.
if (isDictationSetupRequiredError(err.message)) {
setShowDictationSetup(true)
return
@@ -1316,8 +1265,7 @@ export default function SessionScreen() {
}
}, [client])
- // Re-read on focus so a Dictation Mode change made in Settings ▸ Voice is
- // reflected when the user returns to the session.
+ // Re-read on focus so a Settings ▸ Voice dictation-mode change is reflected on return.
useFocusEffect(
useCallback(() => {
void refreshDictationMode()
@@ -1339,9 +1287,7 @@ export default function SessionScreen() {
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(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.
+ // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
layoutSeqRef.current.delete(handle)
clearNativeChatInputLease(handle)
},
@@ -1367,9 +1313,7 @@ export default function SessionScreen() {
}
}, [clearNativeChatInputLease])
- // Why: measures the phone viewport once from the first available TerminalWebView.
- // The viewport dims are passed with every subscribe call so the server can
- // auto-fit the PTY without a separate RPC round-trip.
+ // Why: measure the phone viewport once from the first TerminalWebView; dims ride every subscribe so the server auto-fits without a separate RPC.
const measureViewportOnce = useCallback(
async (handle: string) => {
if (viewportMeasuredRef.current) {
@@ -1409,8 +1353,7 @@ export default function SessionScreen() {
activeHandleRef.current,
handle
)
- // Why: a native-chat-covered terminal subscribes as the input-floor lease
- // without a mounted xterm webview, so only gate on the webview when NOT covered.
+ // Why: a native-chat-covered terminal has no mounted webview, so only gate on the webview when not covered.
if (!covered) {
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
@@ -1427,10 +1370,7 @@ export default function SessionScreen() {
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.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
- // race and the measure→resize→resubscribe pipeline.
+ // Why: viewport is embedded in the subscribe params so the server auto-fits before serializing scrollback (no focus→safeFit race).
const unsub = subscribeMobileTerminalSafely(
client,
{
@@ -1453,8 +1393,7 @@ export default function SessionScreen() {
markNativeChatInputLeaseReady(handle)
return
}
- // Why: retain the subscription as the mobile input-floor lease, but
- // do not mutate covered xterm state; return-to-terminal resubscribes.
+ // Why: keep the subscription as the input-floor lease but don't mutate covered xterm state; return-to-terminal resubscribes.
if (
nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
@@ -1464,15 +1403,7 @@ export default function SessionScreen() {
) {
return
}
- // 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).
+ // Why: drop `resized` events older than the seen seq (superseded layout); scrollback always resets the mark, else reconnect blanks the terminal.
const eventSeq = typeof data.seq === 'number' ? data.seq : null
if (eventSeq != null && data.type === 'resized') {
const last = layoutSeqRef.current.get(handle)
@@ -1508,11 +1439,7 @@ export default function SessionScreen() {
: ''
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
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.
+ // Why: only mark initialized once init() reaches the WebView, else later scrollback is dropped and the terminal stays blank.
if (!ref) {
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
handle: handle.slice(-8),
@@ -1528,21 +1455,9 @@ export default function SessionScreen() {
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
)
}
- // 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.
+ // Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
scheduleDelayedAction(() => 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 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.
+ // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
@@ -1550,14 +1465,7 @@ export default function SessionScreen() {
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.
+ // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await getTerminalRef(handle)?.awaitReady()
if (subscribeSeqRef.current.get(handle) !== seq) {
return
@@ -1565,25 +1473,14 @@ export default function SessionScreen() {
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
- // 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.
+ // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe 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.
+ // Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
@@ -1598,11 +1495,7 @@ export default function SessionScreen() {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
- // 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).
+ // Why: missing ref is the likely cause of "blank but input works" — writes dropped after mid-flight unmount or scrollback never landed.
const dataRef = getTerminalRef(handle)
if (!dataRef) {
console.log('[fit][session] data DROPPED — no terminal ref', {
@@ -1621,12 +1514,7 @@ export default function SessionScreen() {
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
- // Why: inline resize event — the server changed the PTY dimensions
- // (mode toggle, desktop restore, or a width reflow). When the server
- // includes a fresh full-buffer snapshot (width reflow), reinitialize
- // xterm at the new dims so the hard-wrapped scrollback rewraps;
- // preserve the reader's scroll position across the replay. Otherwise
- // resize xterm geometry and let the TUI's own redraw repaint.
+ // Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
@@ -1670,9 +1558,7 @@ export default function SessionScreen() {
unsubscribe: unsubscribeTerminal
})
- // Why: toggles between phone and desktop mode via server RPC. The server
- // handles the actual resize and emits a 'resized' event on the existing
- // subscription stream — no client-side state tracking needed.
+ // Why: server does the resize and emits 'resized' on the existing subscription — no client-side state tracking needed.
const toggleInFlightRef = useRef>(new Set())
const toggleDisplayMode = useCallback(
async (handle: string) => {
@@ -1683,8 +1569,7 @@ export default function SessionScreen() {
return
}
const current = terminalModes.get(handle) ?? 'auto'
- // Why: 'phone' on the wire is an observation ("currently phone-fitted"),
- // not a setting. The toggle only ever requests 'auto' or 'desktop'.
+ // Why: 'phone' is an observed state, not a setting; the toggle only requests 'auto' or 'desktop'.
const next: 'auto' | 'desktop' =
current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
toggleInFlightRef.current.add(handle)
@@ -1692,14 +1577,11 @@ export default function SessionScreen() {
await client.sendRequest('terminal.setDisplayMode', {
terminal: handle,
mode: next,
- // Why: presence-lock take-floor signal — requesting 'auto' is the
- // explicit "I want to drive at phone dims" gesture.
+ // Why: presence-lock take-floor — requesting 'auto' is the explicit "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.
+ // Why: late-bind viewport for terminals subscribed before measurement, or auto toggles no-op on a null stored viewport.
...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})
})
} catch {
@@ -1735,19 +1617,14 @@ export default function SessionScreen() {
if (result.terminals.length === 0 && !allowEmptyLoaded) {
return
}
- // Why: protect against transient empty responses from the server
- // during rapid tab switching or RPC timing. If we previously had
- // terminals and the server now says 0, require a second consecutive
- // empty to confirm. This prevents the UI from flashing empty during
- // rapid interactions while still allowing genuine cleanup.
+ // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty.
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
lastKnownTerminalCountRef.current = 0
return
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
- // Why: terminal.list is the lifetime signal; session-tab snapshots can lag
- // mobile-created tabs and must not erase a user's buffered-mode opt-out.
+ // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
pruneTerminalHandlesFromLiveInput(liveHandles)
defaultTerminalHandlesToLiveInput([...liveHandles])
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
@@ -1767,11 +1644,7 @@ export default function SessionScreen() {
}
}
lastKnownTerminalCountRef.current = result.terminals.length
- // 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.
+ // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) {
@@ -1791,8 +1664,7 @@ export default function SessionScreen() {
)
terminalsRef.current = mergedTerminals
- // Session tabs are the UI authority. terminal.list only refreshes
- // per-handle metadata for existing ready terminal surfaces.
+ // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces.
}
} catch {
// Failed to list terminals
@@ -1814,8 +1686,7 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
- // Reject out-of-order snapshots, then suppress just-closed tabs until the
- // publisher confirms their absence. See session-tab-snapshot-gate.
+ // Reject stale snapshots; suppress just-closed tabs until the publisher confirms absence — see session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
return
}
@@ -1837,8 +1708,7 @@ export default function SessionScreen() {
tab.type === 'markdown' && tab.id === tabId
)
if (draftTab) {
- // Why: save-only mobile edits live only on the phone until Save. If the
- // desktop tab disappears, keep every local draft reachable for copy/discard.
+ // Why: mobile edits live on the phone until Save; if the desktop tab vanishes, keep drafts reachable for copy/discard.
orphanedDraftTabs.push({ ...draftTab, isActive: tabId === activeSessionTabIdRef.current })
}
}
@@ -1846,8 +1716,7 @@ export default function SessionScreen() {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
sessionTabsRef.current = nextTabs
- // Why: subscribe snapshots often repeat identical tab payloads. Avoid a
- // render loop where the subscription effect tears down and replays itself.
+ // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
const terminalTabHandles = terminalTabs.map((terminal) => terminal.handle)
@@ -1881,8 +1750,7 @@ export default function SessionScreen() {
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
- // Why: desktop tab snapshots can lag a mobile tap while activate RPC
- // is in flight. Keep the locally selected tab to avoid snapping back.
+ // Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
@@ -1908,9 +1776,7 @@ export default function SessionScreen() {
selectionSource = 'pending-handle-local-ack'
}
} else if (pendingTerminalTab) {
- // Why: desktop active flags can lag a mobile terminal tap. Key by
- // terminal handle too, because fallback PTY tabs may not yet have a
- // stable session tab id during new-worktree startup.
+ // Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
@@ -1997,10 +1863,7 @@ export default function SessionScreen() {
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
}
- // Why: a headless host (no desktop renderer) can't serve the live editor
- // document and fails markdown.readTab with renderer_unavailable. Fall back
- // to the on-disk file so markdown still renders read-only, matching how
- // other file types load via files.read.
+ // Why: a headless host fails markdown.readTab (renderer_unavailable); fall back to the on-disk file for read-only render.
const fallback = await client.sendRequest('files.read', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
@@ -2261,8 +2124,7 @@ export default function SessionScreen() {
router.back()
return
}
- // Why: Android back can arrive when this session is the root route; using
- // replace avoids React Navigation's dev-only unhandled GO_BACK warning.
+ // Why: Android back can fire at the root route; replace avoids React Navigation's dev-only GO_BACK warning.
router.replace(`/h/${hostId}`)
}, [hostId, router])
@@ -2409,8 +2271,7 @@ export default function SessionScreen() {
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
- // Focus a just-opened browser tab once it appears in the snapshot, via the
- // normal activate path so it sticks and the user can still switch away.
+ // Focus a just-opened browser tab when it appears, via the normal activate path so it sticks yet stays switchable.
const pendingPageId = pendingBrowserFocusPageIdRef.current
if (pendingPageId) {
const browserTab = result.tabs.find(
@@ -2448,9 +2309,15 @@ export default function SessionScreen() {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
+ setQuickCommandsSupported(null)
+ setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
return
}
+ // Why: a client swap can keep the route connected while moving to an older
+ // host; clear the prior capability before exposing host-specific actions.
+ setQuickCommandsSupported(null)
+ setShowQuickCommands(false)
let stale = false
void client
.sendRequest('status.get')
@@ -2465,6 +2332,7 @@ export default function SessionScreen() {
setAgentSessionHistorySupported(
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
)
+ setQuickCommandsSupported(supportsMobileQuickCommands(status.capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current =
@@ -2474,6 +2342,8 @@ export default function SessionScreen() {
if (!stale) {
setBrowserScreencastSupported(false)
setAgentSessionHistorySupported(false)
+ setQuickCommandsSupported(false)
+ setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
}
})
@@ -2482,10 +2352,7 @@ export default function SessionScreen() {
}
}, [client, connState])
- // 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.
+ // Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
useEffect(() => {
if (!hostId) {
return
@@ -2560,9 +2427,7 @@ export default function SessionScreen() {
for (const terminalRef of terminalRefs.current.values()) {
terminalRef.prepareForForegroundRecovery()
}
- // Why: iOS can resume a live WKWebView with a blank xterm backing store
- // without firing web-ready/reconnect; invalidate the native readiness
- // latch before replay so init waits for the document's pong.
+ // Why: iOS can resume a WKWebView with a blank xterm store and no web-ready; invalidate the latch so init waits for the pong.
const outcome = recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
@@ -2579,10 +2444,7 @@ export default function SessionScreen() {
}
}, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
- // Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of
- // background), so the recovery above defers. Re-run it once the connection
- // is back; otherwise a blanked WKWebView whose socket was merely probed (no
- // stream replay) stays stale until a manual tab switch.
+ // Why: resume lands mid-reconnect (socket dies in bg); re-run recovery once connected or a blanked WKWebView stays stale.
useEffect(() => {
if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) {
return
@@ -2602,9 +2464,7 @@ export default function SessionScreen() {
})
}, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
- // Why: viewport refits for layout changes outside the subscribe path
- // (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
- // see terminal-viewport-refit.ts for the full rationale.
+ // Why: non-subscribe layout refits (tab strip, fold, rotation) live in a dedicated hook — see terminal-viewport-refit.ts.
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
activeHandleRef,
terminalRefs,
@@ -2662,8 +2522,7 @@ export default function SessionScreen() {
}
}, [])
- // Reveal the active tab whenever it changes (e.g. desktop's open tab synced on
- // worktree entry). Defer one frame so freshly mounted tab layouts are recorded.
+ // Reveal the active tab on change; defer one frame so freshly mounted tab layouts are recorded.
useEffect(() => {
const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true))
return () => cancelAnimationFrame(id)
@@ -2693,9 +2552,7 @@ export default function SessionScreen() {
}, [router])
useEffect(() => {
- // Why: Expo can reuse this screen across worktrees. Reset pending
- // keyboard listeners, snapshot floors, and tombstones so prior route state
- // cannot open stale UI or reject the next worktree's first snapshot.
+ // Why: Expo reuses this screen across worktrees; reset route state so it can't open stale UI or reject the next snapshot.
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
sessionTabActionSheetKeyboardHideSubRef.current = null
@@ -2744,18 +2601,11 @@ export default function SessionScreen() {
if (connState !== 'connected') {
return
}
- // Why: the RPC client auto-resends terminal.subscribe on reconnect.
- // Keep the current xterm visible while the binary snapshot hydrates,
- // instead of clearing to a blank "Loading terminals" surface.
+ // Why: keep the current xterm visible while the reconnect snapshot hydrates, not a blank "Loading terminals" surface.
if (initializedHandlesRef.current.size === 0) {
setTerminalsLoaded(false)
}
- // Why: on reconnect the RPC client auto-resends terminal.subscribe and
- // the server sends a fresh scrollback frame. The subscribe handler drops
- // scrollback when initializedHandlesRef already contains the handle, so
- // we'd keep stale pre-disconnect content (and lose any output emitted
- // during the disconnect). Clear the flag so the fresh snapshot calls
- // ref.init(...) and replaces the buffer.
+ // Why: clear the initialized flag so the reconnect scrollback replaces stale content instead of being dropped.
initializedHandlesRef.current.clear()
let disposed = false
const timers: ReturnType[] = []
@@ -2772,8 +2622,7 @@ export default function SessionScreen() {
}
}
if (client && created !== '1') {
- // Why: mobile needs host-owned tabs hydrated for this route, but should
- // not pull other paired clients, especially desktop, into this worktree.
+ // Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
@@ -2864,8 +2713,7 @@ export default function SessionScreen() {
}
void fetchSessionTabs()
void fetchTerminals()
- // Why: the live tab subscription stays mounted for stream ownership,
- // but the fallback list poll should stop while this route is hidden.
+ // Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden.
const interval = setInterval(() => {
void fetchSessionTabs()
void fetchTerminals()
@@ -2874,8 +2722,7 @@ export default function SessionScreen() {
}, [connState, fetchSessionTabs, fetchTerminals])
)
- // Why: pick up the Settings → Terminal text size when returning here — the
- // terminal panes stay mounted, so they update in place.
+ // Why: pick up Settings → Terminal text size on return; panes stay mounted and update in place.
useFocusEffect(
useCallback(() => {
let active = true
@@ -2920,10 +2767,7 @@ export default function SessionScreen() {
}, [])
)
- // Why: unsubscribe the old terminal so the server restores its desktop dims
- // (clearing the phone-fit banner), then subscribe the new terminal with the
- // measured viewport so the server phone-fits it. Also call terminal.focus
- // so the desktop renderer follows the mobile user's active terminal.
+ // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow.
const switchTab = useCallback(
(handle: string) => {
triggerSelection()
@@ -2953,8 +2797,7 @@ export default function SessionScreen() {
if (client) {
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
- // Why: persist selection for headless hosts; the snapshot gate keeps
- // this phone-local acknowledgement from impersonating desktop focus.
+ // Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
@@ -3034,20 +2877,15 @@ export default function SessionScreen() {
if (cached?.status === 'ready' && cached.isDirty) {
return
}
- // Why: desktop clean saves do not carry a reliable content version in the
- // lightweight tab list. Re-read on revisit unless the phone has a draft.
+ // Why: tab list lacks a reliable version for desktop clean saves; re-read on revisit unless the phone has a draft.
void readMarkdownTab(tab)
},
[client, markdownDocs, readFileTab, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId]
)
- // Keep the ref pointing at the latest switchSessionTab so fetchSessionTabs can
- // activate a freshly-synced browser tab without a callback dependency cycle.
+ // Ref to latest switchSessionTab so fetchSessionTabs can activate a synced browser tab without a dependency cycle.
switchSessionTabRef.current = switchSessionTab
- // Why: just store the ref. Subscription is deferred to handleTerminalWebReady
- // which fires after the WebView has loaded xterm.js and is ready to process
- // init messages. This prevents the blank terminal race where init() was
- // queued before the WebView loaded.
+ // Why: only store the ref; subscribe on web-ready to avoid the blank-terminal race (init queued before xterm.js loaded).
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
@@ -3075,12 +2913,7 @@ export default function SessionScreen() {
handle === activeHandleRef.current
)
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.)
+ // Why: WebView reloaded (hot reload / Android churn); old xterm buffer is gone, so resubscribe for a fresh scrollback.
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
if (handle === activeHandleRef.current) {
@@ -3088,17 +2921,8 @@ export default function SessionScreen() {
}
return
}
- // 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. 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.
- // Why: a just-created tab can briefly lose activeHandleRef to a lagging
- // session-tab snapshot; honor the pending marker so its one web-ready
- // subscribe still fires (see handleCreateTerminal).
+ // Why: first subscribe may skip (no WebView ref); await measure so it carries the viewport, else it races measureViewportOnce and skips.
+ // Why: a just-created tab can lose activeHandleRef to a lagging snapshot; honor the pending marker so its web-ready subscribe still fires.
const isIntendedActive = () =>
handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
@@ -3147,9 +2971,7 @@ export default function SessionScreen() {
terminal: activeHandle,
text,
enter: true,
- // Why: presence-lock take-floor signal. Identifies this phone as
- // the active mobile actor so the runtime can resolve multi-mobile
- // contention (most-recent-actor's viewport wins).
+ // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor.
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
@@ -3268,8 +3090,7 @@ export default function SessionScreen() {
const openSessionTabActionSheetAfterKeyboardDismiss = useCallback(
(tab: MobileSessionTab) => {
- // Why: live input can have a queued refocus; action sheets should open after
- // the terminal keyboard is gone, not race it under the drawer.
+ // Why: live input may queue a refocus; open the action sheet after the keyboard is gone, not racing it under the drawer.
sessionTabActionSheetRequestSeqRef.current += 1
const requestSeq = sessionTabActionSheetRequestSeqRef.current
clearSessionTabActionSheetKeyboardListener()
@@ -3327,9 +3148,7 @@ export default function SessionScreen() {
[focusLiveInput]
)
- // Tap on a file path in terminal output → resolve it on the host and open it
- // as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
- // WebView only emits this when the tap landed on a detected path.
+ // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
@@ -3369,9 +3188,7 @@ export default function SessionScreen() {
)
const handleOpenedFileDiffActivationSeqRef = useRef(0)
- // Active tab captured at tap time (before the openDiff RPC). Capturing it when
- // the diff finishes opening would misread a tab the user switched to mid-RPC
- // as the tap-time tab, letting the retry steal focus back to the diff.
+ // Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
const fileOpenStartActiveTabIdRef = useRef(null)
const handleFileOpenStart = useCallback(() => {
fileOpenStartActiveTabIdRef.current = activeSessionTabIdRef.current
@@ -3383,8 +3200,7 @@ export default function SessionScreen() {
let activated = false
const activateOpenedTab = async (): Promise => {
- // Route matching through the shared helper so the deterministic repro
- // test exercises the same logic production runs.
+ // Route matching through the shared helper so the repro test exercises the same logic production runs.
const settled = await activateOpenedSourceControlDiffTab({
relativePath,
activeTabIdAtTap,
@@ -3451,8 +3267,7 @@ export default function SessionScreen() {
current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
)
- // Why: tokens represent terminal control sequences, not WebView messages;
- // one legitimate gesture message may batch up to 32 wheel/key reports.
+ // Why: tokens count terminal control sequences, not WebView messages; one gesture may batch up to 32 wheel/key reports.
if (tokens < sequenceCount) {
terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now })
return false
@@ -3538,12 +3353,7 @@ export default function SessionScreen() {
if (!terminalGestureInputInFlightRef.current.has(handle)) {
void flushTerminalGestureInput(handle)
} else {
- // Why: an RPC is in-flight and the new batch would overflow the
- // pending-sequences cap. Appending preserves the already-queued
- // bytes (which would otherwise be dropped) — the in-flight flush's
- // finally block will pick up the merged queue. The cap is a soft
- // guideline; brief overflow during in-flight is preferable to
- // silently dropping user input.
+ // Why: cap is a soft guideline — append instead of dropping queued bytes; the in-flight flush picks up the merged queue.
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
@@ -3579,8 +3389,7 @@ export default function SessionScreen() {
return
}
const modes = ptyModesRef.current.get(handle)
- // Why: WebView gesture bytes can become PTY input here, so mouse-aware
- // reports stay behind validation and SSH-safe rate limiting.
+ // Why: WebView gesture bytes can become PTY input, so gate mouse reports behind validation and SSH-safe rate limiting.
if (!modes?.altScreen && !isGestureMouseTrackingMode(modes?.mouseTrackingMode)) {
return
}
@@ -3623,18 +3432,10 @@ 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.
+ // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
const repeatTimeoutRef = useRef | null>(null)
const repeatIntervalRef = useRef | 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.
+ // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
@@ -3663,9 +3464,7 @@ export default function SessionScreen() {
if (node !== null) {
return
}
- // Why: terminal subscriptions and route-level timers must clear only on
- // real route detach; client churn during mount can otherwise wipe xterm
- // state mid-subscribe.
+ // Why: clear only on real route detach; client churn during mount would wipe xterm state mid-subscribe.
toastSeqRef.current += 1
clearTerminalCache()
clearToastHideTimer()
@@ -3708,10 +3507,7 @@ export default function SessionScreen() {
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.
+ // Why: Android 13+ shows its own system copy toast; iOS shows none, so only iOS needs our in-app toast.
if (Platform.OS === 'ios') {
showToast('Copied')
}
@@ -3939,7 +3735,10 @@ export default function SessionScreen() {
async function handleCreateTerminal(
agent?: MobileNewTabAgentOption['agent'],
- options?: { initialPrompt?: string; onPromptSent?: () => void }
+ options?: MobileQuickCommandLaunch['options'] & {
+ onPromptSent?: () => void
+ errorToast?: string
+ }
) {
if (!client || creatingTerminalRef.current) {
return
@@ -3949,10 +3748,7 @@ export default function SessionScreen() {
setCreating(true)
setCreateError('')
- // Why: idempotency key so a transport-level retry (reconnect replay) of this
- // create resolves to the same terminal instead of spawning a duplicate. Kept
- // compact (no worktree id) to stay under the schema's length cap; the ref
- // guard above blocks concurrent taps synchronously.
+ // Why: idempotency key so a transport retry (reconnect replay) resolves to the same terminal, not a duplicate; kept compact (no worktree id) for the schema length cap.
const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 10)}`
@@ -3962,14 +3758,17 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
clientMutationId,
+ ...(options?.startupCommand ? { command: options.startupCommand } : {}),
+ ...(options?.startupCommandDelivery
+ ? { startupCommandDelivery: options.startupCommandDelivery }
+ : {}),
+ ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {})
})
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
const created = result.tab
- // Why: unsubscribe the old active terminal so the server restores its
- // desktop dims. Without this, the old terminal's mobile subscription
- // stays alive and its restore timer is never set.
+ // Why: unsubscribe the old terminal so the server restores its desktop dims; otherwise its restore timer is never set.
const prev = activeHandleRef.current
if (prev) {
unsubscribeTerminal(prev)
@@ -3987,10 +3786,7 @@ export default function SessionScreen() {
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
defaultTerminalHandlesToLiveInput([createdHandle])
- // Why: session-tab snapshots can lag the create RPC. Without the
- // handle marker, applySessionTabs snaps activeHandleRef back to the
- // previous terminal, and the new pane's web-ready subscribe (gated
- // on the active handle) is skipped — blank tab until a manual switch.
+ // Why: snapshots lag the create RPC; without this marker applySessionTabs reverts the active handle, blanking the new pane.
pendingActiveTerminalHandleRef.current = createdHandle
activeHandleRef.current = createdHandle
setActiveHandle(createdHandle)
@@ -4019,7 +3815,7 @@ export default function SessionScreen() {
.sendRequest('terminal.send', {
terminal: createdHandle,
text: options.initialPrompt,
- enter: true,
+ enter: options.enter !== false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
@@ -4037,33 +3833,76 @@ export default function SessionScreen() {
throw new Error('Terminal input is locked by another client.')
}
triggerSuccess()
- showToast('Notes sent')
+ showToast(options.successToast ?? 'Notes sent')
options.onPromptSent?.()
})
.catch((err) => {
triggerError()
- showToast(err instanceof Error ? err.message : "Couldn't send notes", 1800)
+ showToast(
+ options.errorToast ??
+ (err instanceof Error ? err.message : "Couldn't send notes"),
+ 1800
+ )
})
+ } else if (options?.successToast) {
+ triggerSuccess()
+ showToast(options.successToast)
}
} else {
- // Why: a prior pending handle must not outlive a create that returned
- // no terminal; web-ready subscribe gates on this ref as active.
+ // Why: a prior pending handle must not outlive a create that returned no terminal; web-ready subscribe gates on this ref.
pendingActiveTerminalHandleRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
}
scheduleDelayedAction(() => void fetchSessionTabs(), 500)
} else {
- setCreateError('Failed to create terminal')
+ const message = options?.errorToast ?? 'Failed to create terminal'
+ setCreateError(message)
+ if (options?.errorToast) {
+ triggerError()
+ showToast(message, 1800)
+ }
}
} catch {
- setCreateError('Failed to create terminal')
+ const message = options?.errorToast ?? 'Failed to create terminal'
+ setCreateError(message)
+ if (options?.errorToast) {
+ triggerError()
+ showToast(message, 1800)
+ }
} finally {
creatingTerminalRef.current = false
setCreating(false)
}
}
+ // Quick commands spawn a fresh terminal tab, mirroring desktop's
+ // run-quick-command-in-new-tab: agent prompts and runnable terminal commands
+ // use the host's shell-ready startup path; insert-only commands stay drafts.
+ function launchQuickCommand(command: TerminalQuickCommand): boolean {
+ if (
+ !client ||
+ connState !== 'connected' ||
+ creatingTerminalRef.current ||
+ creatingBrowser ||
+ creatingMarkdown
+ ) {
+ return false
+ }
+ const launch = buildMobileQuickCommandLaunch(command)
+ if (!launch) {
+ triggerError()
+ showToast('Edit this quick command before running it', 1800)
+ return false
+ }
+ const label = command.label.trim() || 'Quick command'
+ void handleCreateTerminal(launch.agent, {
+ ...launch.options,
+ errorToast: `Couldn't run ${label}`
+ })
+ return true
+ }
+
async function handleCreateMarkdownNote() {
if (!client || creatingMarkdown) {
return
@@ -4114,8 +3953,7 @@ export default function SessionScreen() {
if (!client || creatingBrowser) {
return false
}
- // Why: read via ref so a tap that fires before the capability probe resolves
- // (or from a stale callback) still sees the live support value.
+ // Why: read via ref so a tap before the capability probe resolves (or a stale callback) still sees the live value.
if (browserScreencastSupportedRef.current !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return false
@@ -4144,9 +3982,7 @@ export default function SessionScreen() {
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
- // Focus the new browser tab once it syncs (fetchSessionTabs activates it
- // via the normal path). Refresh a few times since the desktop registers
- // the tab asynchronously.
+ // Focus the new browser tab once it syncs; refresh a few times since the desktop registers the tab asynchronously.
const created = (response as RpcSuccess).result as { browserPageId?: string }
if (created.browserPageId) {
pendingBrowserFocusPageIdRef.current = created.browserPageId
@@ -4164,8 +4000,7 @@ export default function SessionScreen() {
setCreatingBrowser(false)
}
}
- // Keep the ref pointing at the latest handleCreateBrowser so a terminal URL
- // tap (handleTerminalOpenUrl) always runs the current closure.
+ // Keep the ref at the latest handleCreateBrowser so a terminal URL tap always runs the current closure.
handleCreateBrowserRef.current = handleCreateBrowser
async function handleBrowserNavigationCommand(
@@ -4275,9 +4110,7 @@ export default function SessionScreen() {
clearTerminalLiveInputDefault(terminalHandle)
}
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
- // Why: tombstone the closed tab and rely on the subscription/poll
- // snapshot (gated by snapshotVersion) instead of a blind 300ms refetch
- // that re-applied whatever the host had — often the not-yet-closed list.
+ // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
activeSessionTabTypeRef.current = null
@@ -4319,8 +4152,7 @@ export default function SessionScreen() {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
return
}
- // Why: a hydrated headless/server-owned tab can already be active but still
- // pending; activation is the RPC that materializes or focuses its PTY handle.
+ // Why: a server-owned tab can be active but still pending; activation is the RPC that materializes its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
@@ -4369,16 +4201,13 @@ export default function SessionScreen() {
) {
return
}
- // Why: a sleeping/new workspace can hydrate with zero session tabs. Create
- // the first terminal once on initial load instead of leaving mobile blank.
+ // Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank.
initialEmptySessionAutoCreateRef.current = worktreeId
setCreateError('')
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
- // Why: the reconnect loop slows to a 90s trickle at its give-up cap;
- // surface tap-to-retry once the verdict escalates so recovery doesn't
- // wait out the trickle timer (issue #5049).
+ // Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
const connectionVerdict = classifyConnection({
state: connState,
reconnectAttempts,
@@ -4399,9 +4228,7 @@ export default function SessionScreen() {
? `${verdictDisplayLabel(connectionVerdict)} — tap to retry`
: MOBILE_SESSION_STATUS_LABELS[connState]
- // Why: keep safe-area padding in layout at all times, then visually translate
- // the controls over the terminal when the keyboard appears. iOS keyboard
- // height includes the home-indicator inset; Android IME height does not.
+ // Why: iOS keyboard height includes the home-indicator inset; Android IME height does not.
const keyboardLift =
keyboardHeight > 0
? Platform.OS === 'ios'
@@ -4423,8 +4250,7 @@ export default function SessionScreen() {
const cursorBottom = (metrics.cursorY + 1) * rowHeight
const dockTop = terminalFrameHeightRef.current - keyboardLift
const margin = rowHeight
- // Why: only move the terminal when the active cursor would sit under the
- // raised input dock. Short shell output near the top should stay put.
+ // Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
})()
const toastAnimatedStyle = {
@@ -4522,8 +4348,7 @@ export default function SessionScreen() {
]
: []
- // Routes a header panel-icon tap through the pure dock-vs-push decision (U1):
- // measured dock-capable rows toggle/swap, constrained rows push full-screen.
+ // Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push.
const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => {
const width = Math.round(event.nativeEvent.layout.width)
setSessionContentRowWidth((prev) => (prev === width ? prev : width))
@@ -4542,8 +4367,7 @@ export default function SessionScreen() {
hostId,
worktreeId,
name: worktreeName || '',
- // SC + PR both land on the source-control hub; post-diff-open dismissal
- // keys off origin: 'session' (U2). Files keeps its own route without origin.
+ // SC + PR both land on the source-control hub with origin:'session' for post-diff-open dismissal (U2); Files opts out.
...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
@@ -4625,10 +4449,7 @@ export default function SessionScreen() {
{visibleTabs.length > 0 && (
- {/* Why: tab taps must register on the first press while the live
- keyboard is open instead of being eaten by keyboard dismissal
- (#5106); leaving a non-live tab still closes the keyboard
- because the live input unmounts. */}
+ {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
))}
- {/* Why: pinned outside the scroll strip so the new-agent button
- stays reachable no matter how far the tabs scroll. */}
+ {/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */}
[
styles.newTerminalButton,
@@ -4715,14 +4535,19 @@ export default function SessionScreen() {
>
+ {quickCommandsSupported === true ? (
+ setShowQuickCommands(true)}
+ />
+ ) : null}
)}
- {/* Content-row host (KTD2): the header/tab chrome stays a full-width sibling
- above; on wide the post-chrome content shares this row with the docked panel.
- There is no single terminal node, so the entire conditional block is the
- flex-1 left child. On narrow the dock never renders and layout is unchanged. */}
+ {/* Content-row host (KTD2): on wide, content shares this row with the docked panel as the flex-1 left child. */}
{createWarning ? (
@@ -4820,8 +4645,7 @@ export default function SessionScreen() {
) : activeBrowserTab ? (
- {/* Why: the pane owns imperative frame refs; browser tabs should
- never render a stale frame while the old stream effect cleans up. */}
+ {/* Why: pane owns imperative frame refs; don't render a stale frame while the old stream effect cleans up. */}
{
terminalFrameHeightRef.current = e.nativeEvent.layout.height
- // Why: notify height imperatively so dock settling re-fits the
- // PTY without rerendering SessionScreen for layout callbacks.
+ // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
@@ -4867,8 +4690,7 @@ export default function SessionScreen() {
terminalTheme={terminal.terminalTheme}
textScale={terminalTextScale}
onTextScaleChange={(scale) => {
- // Why: pinch-to-zoom in the WebView reports a new preset; persist
- // it so the size sticks across panes and app launches.
+ // Why: pinch-to-zoom reports a new preset; persist it so the size sticks across panes and launches.
setTerminalTextScale(scale)
void saveTerminalTextScale(scale)
}}
@@ -4907,9 +4729,7 @@ export default function SessionScreen() {
)}
- {/* Why: translate instead of resizing so keyboard open/close does not
- trigger a server-side PTY viewport change. The dock hides in native
- chat because that view supplies its own composer. */}
+ {/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && (
{/* Accessory keys */}
- {/* Why: a fixed, always-visible escape hatch from the open
- keyboard. Kept outside the horizontal ScrollView so it does
- not scroll away, and out of the terminal-byte shortcut path so
- it cannot be hidden by user shortcut customization (#5106). */}
+ {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
[
@@ -4946,9 +4763,7 @@ export default function SessionScreen() {
)}
- {/* Why: with default tap handling the first tap on any accessory
- key dismisses the open keyboard and is swallowed, so live
- input lost its keyboard on every Esc/Tab press (#5106). */}
+ {/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
setShowHeaderMoreActions(false)}
/>
+ setShowQuickCommands(false)}
+ client={client}
+ repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null}
+ repoName={worktreeName || null}
+ onLaunch={launchQuickCommand}
+ />
+
()
let nextClientIdentity = 1
function clientKey(client: RpcClient): number {
@@ -164,11 +164,7 @@ function fetchWorktreeInfo(
) => void,
disposed: () => boolean
) {
- // 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.
+ // Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
const markLoadedIfMissing = () => {
setInfo((prev) => {
if (prev[hostId]) {
@@ -187,8 +183,7 @@ function fetchWorktreeInfo(
}
client
- // Why: worktree.ps defaults to 200 and silently truncates; request the full
- // set so the host worktree count and active count are accurate.
+ // Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
.sendRequest('worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
@@ -286,8 +281,7 @@ function fetchTaskProviders(
})
}
-// Why: repo names get a stable color derived from hashing, matching the
-// host detail page's colored dots for visual consistency.
+// Why: hash repo name to a stable color, matching the host detail page's dots.
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
function repoColor(name: string): string {
let hash = 0
@@ -300,8 +294,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
- // Why: cap and center content on wide/tablet canvases so cards don't stretch
- // edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged.
+ // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState([])
const [actionTarget, setActionTarget] = useState(null)
@@ -316,11 +309,11 @@ export default function HomeScreen() {
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
- const notificationOptInCheckedRef = useRef(false)
+ // Why: focus can fire repeatedly while an async gate is pending; one probe per
+ // mount avoids duplicate storage/permission reads and competing navigation.
+ const onboardingOptInCheckedRef = useRef(false)
- // 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.
+ // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
@@ -330,27 +323,20 @@ export default function HomeScreen() {
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).
+ // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
useEffect(() => {
if (hosts.length > 0) {
primeHosts(hosts)
}
}, [hosts, primeHosts])
const allClientsRef = useRef>([])
- // Why: the focus callback stays stable to avoid refetching on every
- // client-store render, but it still needs the latest host clients.
+ // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients.
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
- // 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.
+ // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty.
const hydratedRef = useRef(false)
useEffect(() => {
if (hydratedRef.current) {
@@ -367,8 +353,7 @@ export default function HomeScreen() {
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.
+ // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object.
setCachedWorktrees(hostId, [wt])
}
}
@@ -378,9 +363,7 @@ export default function HomeScreen() {
}
}, [])
- // 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.
+ // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes).
useEffect(() => {
if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
return
@@ -400,13 +383,16 @@ export default function HomeScreen() {
return
}
setHosts(h)
- if (h.length === 0 || notificationOptInCheckedRef.current) {
+ if (h.length === 0 || onboardingOptInCheckedRef.current) {
return
}
- notificationOptInCheckedRef.current = true
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
- if (!stale && showNotificationOptIn) {
- router.replace('/notification-opt-in')
+ onboardingOptInCheckedRef.current = true
+ const onboardingSteps = await loadMobileOnboardingSteps()
+ if (stale) {
+ return
+ }
+ if (onboardingSteps.length > 0) {
+ router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
@@ -436,8 +422,7 @@ export default function HomeScreen() {
[hosts]
)
- // Why: mirror per-host connection state into hostStates so existing
- // render code (status dots, connecting indicators) keeps working.
+ // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
useEffect(() => {
setHostAttempts((prev) => {
const next: Record = { ...prev }
@@ -473,11 +458,7 @@ export default function HomeScreen() {
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'.
+ // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
for (const host of hosts) {
if (liveIds.has(host.id)) {
continue
@@ -506,11 +487,7 @@ export default function HomeScreen() {
})
}, [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.
+ // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
@@ -563,12 +540,7 @@ export default function HomeScreen() {
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).
+ // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
allClients
@@ -577,22 +549,10 @@ export default function HomeScreen() {
.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.
+ // Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
+ // Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC 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.
+ // Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
@@ -612,9 +572,7 @@ export default function HomeScreen() {
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
- // 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.
+ // Why: only show Account usage for connected hosts; stale cached usage would imply live data.
const accountsHosts = useMemo(() => {
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
for (const host of sortedHosts) {
@@ -625,9 +583,7 @@ export default function HomeScreen() {
if (!snap) {
continue
}
- // Why: also show hosts whose only usage is the system-default login
- // (no Orca-managed accounts but live rate-limit data for the active
- // target), otherwise system-default users see no usage section at all.
+ // Why: also show hosts whose only usage is the system-default login, else those users see no usage section.
if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
items.push({ host, snapshot: snap })
}
@@ -716,8 +672,7 @@ export default function HomeScreen() {
setConfirmRemove(null)
setHosts(await loadHosts())
} catch {
- // Why: ConfirmModal closes on confirm; re-open for retry and surface the
- // failure instead of silently leaving the host listed.
+ // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
setConfirmRemove(hostToRemove)
Alert.alert('Could not remove host', 'Please try again.')
}
@@ -782,9 +737,7 @@ export default function HomeScreen() {
h.id}
- // Why: edge-to-edge — let the list scroll under the system nav bar
- // but reserve insets.bottom so the last row stays reachable above
- // the Samsung 3-button nav / iOS home indicator.
+ // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
contentContainerStyle={[
styles.list,
{ paddingBottom: spacing.xl + insets.bottom },
@@ -961,10 +914,7 @@ export default function HomeScreen() {
? snapshot.claude.accounts
: snapshot.codex.accounts
const limits = getActiveProviderRateLimits(snapshot, provider)
- // Why: with no managed accounts, still render a
- // "System default" row when the active target has
- // live usage data; the row label already falls back
- // to "System default" below.
+ // Why: with no managed accounts, still render the row when the active target has live usage data.
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
@@ -1027,11 +977,7 @@ export default function HomeScreen() {
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.
+ // Why: label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action.
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
const items: ActionSheetAction[] = []
items.push({
diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx
new file mode 100644
index 00000000000..50957a465fc
--- /dev/null
+++ b/mobile/app/mobile-onboarding.tsx
@@ -0,0 +1,215 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ AccessibilityInfo,
+ Animated,
+ BackHandler,
+ Text,
+ useWindowDimensions,
+ View
+} from 'react-native'
+import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
+import { SafeAreaView } from 'react-native-safe-area-context'
+import { OrcaLogo } from '../src/components/OrcaLogo'
+import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
+import {
+ MobileOnboardingPage,
+ type MobileOnboardingBusyChoice,
+ type NotificationOnboardingChoice
+} from '../src/onboarding/MobileOnboardingPage'
+import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan'
+import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles'
+import {
+ saveDefaultSessionView,
+ type MobileSessionView
+} from '../src/storage/session-view-preferences'
+import { savePushNotificationsEnabled } from '../src/storage/preferences'
+
+const SLIDE_DURATION_MS = 280
+
+export default function MobileOnboardingScreen() {
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ steps?: string | string[]
+ }>()
+ const hostId = firstParam(params.hostId)
+ const rawSteps = firstParam(params.steps)
+
+ return (
+
+ )
+}
+
+function MobileOnboardingFlow({
+ hostId,
+ rawSteps
+}: {
+ hostId: string | undefined
+ rawSteps: string | undefined
+}) {
+ const router = useRouter()
+ const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps])
+ const { width } = useWindowDimensions()
+ const [activeIndex, setActiveIndex] = useState(0)
+ const [busyChoice, setBusyChoice] = useState(null)
+ const [error, setError] = useState(null)
+ const choiceInFlightRef = useRef(false)
+ const slideProgress = useRef(new Animated.Value(0)).current
+ const reducedMotionEnabled = useReducedMotionEnabled()
+
+ // Why: onboarding requires an explicit choice for every planned step; disabling
+ // stack gestures alone still leaves Android hardware Back able to skip the flow.
+ useFocusEffect(
+ useCallback(() => {
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
+ return () => subscription.remove()
+ }, [])
+ )
+
+ const continueToApp = useCallback(() => {
+ router.replace(hostId ? `/h/${hostId}` : '/')
+ }, [hostId, router])
+
+ const advanceOrContinue = useCallback(() => {
+ const nextIndex = activeIndex + 1
+ if (nextIndex >= steps.length) {
+ continueToApp()
+ return
+ }
+ setActiveIndex(nextIndex)
+ setError(null)
+ Animated.timing(slideProgress, {
+ toValue: nextIndex,
+ // Why: the carousel should preserve continuity without overriding the
+ // device's reduced-motion preference.
+ duration: reducedMotionEnabled ? 0 : SLIDE_DURATION_MS,
+ useNativeDriver: true
+ }).start(() => {
+ // Why: a cancelled cosmetic transition must not leave the next decision
+ // permanently disabled after the previous choice was already persisted.
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ })
+ }, [activeIndex, continueToApp, reducedMotionEnabled, slideProgress, steps.length])
+
+ const chooseSessionView = useCallback(
+ async (view: MobileSessionView) => {
+ // Why: state does not disable both buttons synchronously; the ref prevents
+ // rapid taps from persisting conflicting choices or advancing twice.
+ if (choiceInFlightRef.current) {
+ return
+ }
+ choiceInFlightRef.current = true
+ setBusyChoice(view)
+ setError(null)
+ try {
+ await saveDefaultSessionView(view)
+ advanceOrContinue()
+ } catch {
+ setError('Your choice could not be saved. Try again.')
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ }
+ },
+ [advanceOrContinue]
+ )
+
+ const chooseNotifications = useCallback(
+ async (choice: NotificationOnboardingChoice) => {
+ if (choiceInFlightRef.current) {
+ return
+ }
+ choiceInFlightRef.current = true
+ setBusyChoice(choice)
+ setError(null)
+ try {
+ const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
+ await savePushNotificationsEnabled(enabled)
+ advanceOrContinue()
+ } catch {
+ setError('Notification settings could not be updated. Try again.')
+ setBusyChoice(null)
+ choiceInFlightRef.current = false
+ }
+ },
+ [advanceOrContinue]
+ )
+
+ const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width])
+
+ return (
+
+
+
+ Orca
+ {steps.length > 1 ? (
+
+ {steps.map((step, index) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {steps.map((step, index) => (
+ void chooseSessionView(view)}
+ onNotificationChoice={(choice) => void chooseNotifications(choice)}
+ />
+ ))}
+
+
+
+ )
+}
+
+function firstParam(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value
+}
+
+function useReducedMotionEnabled(): boolean {
+ const [enabled, setEnabled] = useState(false)
+
+ useEffect(() => {
+ let mounted = true
+ void AccessibilityInfo.isReduceMotionEnabled()
+ .then((nextEnabled) => {
+ if (mounted) {
+ setEnabled(nextEnabled)
+ }
+ })
+ .catch(() => undefined)
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setEnabled)
+ return () => {
+ mounted = false
+ subscription.remove()
+ }
+ }, [])
+
+ return enabled
+}
diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx
deleted file mode 100644
index a4476687e09..00000000000
--- a/mobile/app/notification-opt-in.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { useCallback, useState } from 'react'
-import {
- ActivityIndicator,
- BackHandler,
- Pressable,
- ScrollView,
- StyleSheet,
- Text,
- View
-} from 'react-native'
-import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
-import { SafeAreaView } from 'react-native-safe-area-context'
-import { BellRing } from 'lucide-react-native'
-import { OrcaLogo } from '../src/components/OrcaLogo'
-import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
-import { savePushNotificationsEnabled } from '../src/storage/preferences'
-import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
-
-export default function NotificationOptInScreen() {
- const router = useRouter()
- const params = useLocalSearchParams<{ hostId?: string | string[] }>()
- const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
- const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
- const [error, setError] = useState(null)
-
- // Why: this one-time screen requires an explicit Enable or Not now choice;
- // disabling back gestures alone would still leave Android hardware back open.
- useFocusEffect(
- useCallback(() => {
- const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
- return () => subscription.remove()
- }, [])
- )
-
- const continueToApp = useCallback(() => {
- router.replace(hostId ? `/h/${hostId}` : '/')
- }, [hostId, router])
-
- const choose = useCallback(
- async (choice: 'enable' | 'skip') => {
- if (busyChoice) {
- return
- }
- setBusyChoice(choice)
- setError(null)
- try {
- const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
- await savePushNotificationsEnabled(enabled)
- continueToApp()
- } catch {
- setError('Notification settings could not be updated. Try again.')
- setBusyChoice(null)
- }
- },
- [busyChoice, continueToApp]
- )
-
- return (
-
-
-
-
- Orca
-
-
-
-
-
-
- Notifications
- Stay updated while away
-
- Get notified on this device when an agent needs your input or finishes a task.
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
- [
- styles.primaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('enable')}
- >
- {busyChoice === 'enable' ? (
-
- ) : (
- Enable notifications
- )}
-
- [
- styles.secondaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('skip')}
- >
- {busyChoice === 'skip' ? (
-
- ) : (
- Not now
- )}
-
- You can change this any time in Settings.
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- paddingHorizontal: spacing.xl
- },
- // Why: this decision screen cannot be dismissed with Back, so every action
- // must remain reachable in landscape and with accessibility text scaling.
- scrollContent: {
- flexGrow: 1
- },
- brandRow: {
- minHeight: 52,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- brandName: {
- color: colors.textPrimary,
- fontSize: 17,
- fontWeight: '700'
- },
- content: {
- flexGrow: 1,
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: spacing.xl
- },
- iconSurface: {
- width: 64,
- height: 64,
- borderRadius: radii.card,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgRaised,
- marginBottom: spacing.xl
- },
- eyebrow: {
- color: colors.textMuted,
- fontSize: 11,
- fontWeight: '600',
- letterSpacing: 0.55,
- textTransform: 'uppercase',
- marginBottom: spacing.sm
- },
- title: {
- maxWidth: 420,
- color: colors.textPrimary,
- fontSize: 26,
- fontWeight: '700',
- letterSpacing: -0.3,
- textAlign: 'center'
- },
- body: {
- maxWidth: 420,
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- lineHeight: 21,
- textAlign: 'center',
- marginTop: spacing.md
- },
- footer: {
- width: '100%',
- maxWidth: 420,
- alignSelf: 'center',
- paddingBottom: spacing.lg
- },
- primaryButton: {
- minHeight: 44,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- backgroundColor: colors.surfaceBright,
- paddingVertical: spacing.sm
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- secondaryButton: {
- minHeight: 44,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- marginTop: spacing.xs,
- paddingVertical: spacing.sm
- },
- secondaryButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- },
- buttonPressed: {
- opacity: 0.72
- },
- buttonDisabled: {
- opacity: 0.58
- },
- footerNote: {
- color: colors.textMuted,
- fontSize: typography.metaSize,
- lineHeight: 18,
- textAlign: 'center',
- marginTop: spacing.sm
- },
- error: {
- color: colors.statusRed,
- fontSize: typography.metaSize,
- lineHeight: 18,
- textAlign: 'center',
- marginBottom: spacing.sm
- }
-})
diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx
index d4505b82411..0621caa4f01 100644
--- a/mobile/app/pair-confirm.tsx
+++ b/mobile/app/pair-confirm.tsx
@@ -12,7 +12,10 @@ import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
-import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination
+} from '../src/onboarding/mobile-onboarding-plan'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
@@ -113,15 +116,11 @@ export default function PairConfirmScreen() {
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
+ const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
}
- router.replace(
- showNotificationOptIn
- ? { pathname: '/notification-opt-in', params: { hostId } }
- : `/h/${hostId}`
- )
+ router.replace(mobileOnboardingDestination(onboardingSteps, hostId))
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index 6b980ee0be6..892c1b359a1 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -22,7 +22,10 @@ import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
-import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination
+} from '../src/onboarding/mobile-onboarding-plan'
// 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
@@ -157,15 +160,11 @@ export default function PairScanScreen() {
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
- const showNotificationOptIn = await shouldPresentNotificationOptIn()
+ const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
}
- router.replace(
- showNotificationOptIn
- ? { pathname: '/notification-opt-in', params: { hostId } }
- : `/h/${hostId}`
- )
+ router.replace(mobileOnboardingDestination(onboardingSteps, hostId))
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts
index 22b725ad129..6e889e5ac23 100644
--- a/mobile/scripts/mock-server-rpc-handlers.ts
+++ b/mobile/scripts/mock-server-rpc-handlers.ts
@@ -3,6 +3,11 @@ import {
DESKTOP_PROTOCOL_VERSION,
MIN_COMPATIBLE_MOBILE_VERSION
} from '../../src/shared/protocol-version'
+import {
+ applyTerminalQuickCommandMutation,
+ type TerminalQuickCommandMutation
+} from '../../src/shared/terminal-quick-commands'
+import type { TerminalQuickCommand } from '../../src/shared/types'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
@@ -15,6 +20,27 @@ const MOCK_RPC_DELAY_MS = readScenarioNumber('MOCK_RPC_DELAY_MS', 0)
const FAKE_REPOS = createMockRepos(MOCK_REPO_COUNT)
let fakeWorktrees = createMockWorktrees(FAKE_REPOS, MOCK_WORKTREE_COUNT)
+// Mutable quick-command list so the mobile Quick Commands sheet can add/edit/
+// delete against the mock the same way it does a paired desktop.
+let fakeQuickCommands: TerminalQuickCommand[] = [
+ {
+ id: 'qc-codex-review',
+ label: 'codex review',
+ action: 'agent-prompt',
+ agent: 'codex',
+ prompt: 'please review this diff for correctness and edge cases.',
+ scope: { type: 'global' }
+ },
+ {
+ id: 'qc-dev-server',
+ label: 'dev server',
+ action: 'terminal-command',
+ command: 'pnpm dev',
+ appendEnter: true,
+ scope: { type: 'global' }
+ }
+]
+
const FAKE_TERMINALS = [
{
handle: 'term-1',
@@ -145,6 +171,19 @@ export function handleRequest(
)
break
+ case 'settings.getTerminalQuickCommands':
+ respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
+ break
+
+ case 'settings.updateTerminalQuickCommands': {
+ const updates = (request.params ?? {}) as { mutation?: TerminalQuickCommandMutation }
+ if (updates.mutation) {
+ fakeQuickCommands = applyTerminalQuickCommandMutation(fakeQuickCommands, updates.mutation)
+ }
+ respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
+ break
+ }
+
case 'ui.get':
respond(
success(request.id, {
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index d3866393116..7e1444d9b0c 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -17,9 +17,7 @@ type NotificationEvent = {
body: string
worktreeId?: string
notificationId?: string
- // Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
- // for reconnect catch-up (#8129). Optional because older runtimes / non-
- // replay events may omit it.
+ // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
}
@@ -42,12 +40,7 @@ type ScheduledNotificationState = {
const scheduledNotificationsByHostAndNotificationId = new Map()
-// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
-// so every agent-task-complete inserts a new, never-reused key. Entries are only
-// removed when the desktop sends a matching dismiss — which a remote mobile user
-// (not at the desktop) frequently never gets — so the map grew for the app's whole
-// life. Bound it; a settled entry only retains a small identifier used for later
-// programmatic dismissal, unnecessary for long-past completions.
+// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
@@ -55,8 +48,7 @@ function getStoredNotificationKey(hostId: string, notificationId: string): strin
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
-// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
-// Map iteration is insertion order, so the first match is the oldest.
+// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
@@ -91,16 +83,13 @@ export async function getNotificationPermissionState(): Promise= 33)
}
}
-// Why: permissions must be requested before scheduling any local notification.
-// Read the OS state every time because users can change it in Settings while
-// Orca remains alive in the background.
+// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise {
const existing = await getNotificationPermissionState()
if (existing.granted) {
@@ -225,8 +214,7 @@ async function dismissLocalNotification(
return
}
if (state.pending) {
- // Why: desktop can send dismiss while iOS/Android is still scheduling the
- // matching local notification. Remember it so no stale banner survives.
+ // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
@@ -237,29 +225,15 @@ async function dismissLocalNotification(
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
-// Why: each host connection gets its own notification subscription. When the
-// connection drops, the unsubscribe function cleans up the streaming RPC.
-// On reconnect the same subscribe stream is re-established by the RPC client;
-// we use its `ready` event to trigger catch-up (#8129): fetch notifications
-// dispatched while the socket was reaped, watermarked by the last seq we
-// already delivered so the desktop never re-sends an already-pushed one.
-// Returns an unsubscribe function.
+// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
- // Highest seq delivered on the live stream or replay for this connection.
- // Persisted per-host so a cold app start still resumes from the right cut.
+ // Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
let lastDeliveredSeq = 0
- // Why: per-connection dedup guard applied ONLY to the replay path
- // (fetchMissed), never the live stream. The desktop already guarantees the
- // replay cannot contain an event with seq <= lastDeliveredSeq (both live and
- // replay advance the same watermark), so live + replay never overlap there.
- // This set is defense-in-depth: if the desktop's bounded buffer evicted an
- // old entry and a reconnect re-fetches across a boundary, an id delivered in
- // the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
- // long-lived session can't grow without limit.
+ // Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
const seenReplay = createSeenNotificationGuard()
function deliverLive(
@@ -270,10 +244,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
- // Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
- // replay) so a replay that re-includes an id already pushed live in this
- // connection is dropped instead of double-pushed. fetchMissed also
- // pre-checks seenReplay, but without this the live path never populated it.
+ // Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
@@ -284,12 +255,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
- // Why: on a reconnect `ready` the desktop has already dispatched whatever we
- // missed; ask for it from our persisted watermark. Because the desktop cuts
- // by seq > lastSeenSeq this is idempotent — we only ever get events we have
- // not delivered before. The seenReplay guard is a second layer so a replay
- // that somehow re-includes an id already delivered this connection is
- // dropped instead of double-pushed.
+ // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
async function fetchMissed(): Promise {
if (disposed) {
return
@@ -321,10 +287,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
- // Why: lazily seed the watermark from durable storage on first use so we
- // don't block subscribe() on an AsyncStorage read. The first `ready` (cold
- // open) does NOT need catch-up — the live stream starts fresh; only
- // subsequent reconnect `ready` events fetch missed notifications.
+ // Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
@@ -352,10 +315,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
unsubscribeStream()
return
}
- // Why: first ready is the cold-open live stream — no catch-up needed.
- // Every later ready is a reconnect; fetch what we missed from the
- // watermark. Guard on watermarkLoaded so a fast reconnect doesn't
- // fetch from a stale 0 watermark (which would re-push everything).
+ // Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
@@ -379,12 +339,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return () => {
disposed = true
- // Why: the client may already be closed when this cleanup runs (component
- // unmount races with disconnect). sendRequest rejects immediately on a
- // closed client — swallow it since server-side cleanup happens via
- // connection-close anyway.
- // Always drop the local stream first; readiness can race unmount and we
- // must not retain the callback while waiting for a subscription id.
+ // Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
unsubscribeServer(subscriptionId)
diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts
new file mode 100644
index 00000000000..f3683105160
--- /dev/null
+++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts
@@ -0,0 +1,103 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MobileOnboardingPage } from './MobileOnboardingPage'
+
+vi.mock('react-native', async () => {
+ const React = await import('react')
+ return {
+ ActivityIndicator: 'ActivityIndicator',
+ Pressable: 'Pressable',
+ ScrollView: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('ScrollView', props, children),
+ StyleSheet: { create: (styles: unknown) => styles },
+ Text: 'Text',
+ View: 'View'
+ }
+})
+
+vi.mock('lucide-react-native', () => ({
+ BellRing: 'BellRing',
+ MessageSquare: 'MessageSquare'
+}))
+
+describe('MobileOnboardingPage', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ async function renderPage(
+ step: 'session-view' | 'notifications',
+ options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {}
+ ) {
+ const onSessionChoice = vi.fn()
+ const onNotificationChoice = vi.fn()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
+ throw new Error(String(args[0]))
+ }
+ })
+ await act(async () => {
+ renderer = create(
+ createElement(MobileOnboardingPage, {
+ step,
+ width: 390,
+ active: options.active ?? true,
+ busyChoice: options.busyChoice ?? null,
+ error: null,
+ onSessionChoice,
+ onNotificationChoice
+ })
+ )
+ })
+ consoleError.mockRestore()
+ return { onSessionChoice, onNotificationChoice }
+ }
+
+ function button(label: string) {
+ return renderer!.root.find(
+ (node) => node.type === 'Pressable' && node.props.accessibilityLabel === label
+ )
+ }
+
+ it('renders the session choices and sends exactly one selected view', async () => {
+ const callbacks = await renderPage('session-view')
+
+ act(() => button('Open sessions in native chat').props.onPress())
+ expect(callbacks.onSessionChoice).toHaveBeenCalledWith('chat')
+ expect(callbacks.onNotificationChoice).not.toHaveBeenCalled()
+ })
+
+ it('renders the notification choices and sends the selected option', async () => {
+ const callbacks = await renderPage('notifications')
+ act(() => button('Skip notifications for now').props.onPress())
+
+ expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip')
+ expect(callbacks.onSessionChoice).not.toHaveBeenCalled()
+ })
+
+ it('disables both notification choices while permission is pending', async () => {
+ await renderPage('notifications', { busyChoice: 'enable' })
+ const enable = button('Enable agent notifications')
+ const secondary = button('Skip notifications for now')
+
+ expect(enable.props.disabled).toBe(true)
+ expect(secondary.props.disabled).toBe(true)
+ })
+
+ it('hides an off-screen page from assistive technology', async () => {
+ await renderPage('notifications', { active: false })
+ const scrollView = renderer!.root.findByType('ScrollView')
+
+ expect(scrollView.props.accessibilityElementsHidden).toBe(true)
+ expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants')
+ })
+})
diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx
new file mode 100644
index 00000000000..f7bbff0cf0d
--- /dev/null
+++ b/mobile/src/onboarding/MobileOnboardingPage.tsx
@@ -0,0 +1,173 @@
+import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
+import { BellRing, MessageSquare } from 'lucide-react-native'
+import type { MobileOnboardingStep } from './mobile-onboarding-plan'
+import { mobileOnboardingStyles as styles } from './mobile-onboarding-styles'
+import type { MobileSessionView } from '../storage/session-view-preferences'
+import { colors } from '../theme/mobile-theme'
+
+export type NotificationOnboardingChoice = 'enable' | 'skip'
+export type MobileOnboardingBusyChoice = MobileSessionView | NotificationOnboardingChoice | null
+
+type Props = {
+ step: MobileOnboardingStep
+ width: number
+ active: boolean
+ busyChoice: MobileOnboardingBusyChoice
+ error: string | null
+ onSessionChoice: (view: MobileSessionView) => void
+ onNotificationChoice: (choice: NotificationOnboardingChoice) => void
+}
+
+export function MobileOnboardingPage({
+ step,
+ width,
+ active,
+ busyChoice,
+ error,
+ onSessionChoice,
+ onNotificationChoice
+}: Props) {
+ const busy = busyChoice !== null
+ const isSessionView = step === 'session-view'
+
+ return (
+
+
+
+ {isSessionView ? (
+
+ ) : (
+
+ )}
+
+
+ {isSessionView ? 'How should sessions open?' : 'Stay updated while away'}
+
+
+ {isSessionView
+ ? 'Choose whether supported agent sessions open in the terminal or native chat on this device. Press and hold a session tab to switch its view, or change the default later in Settings.'
+ : 'Get notified on this device when an agent needs your input or finishes a task.'}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {isSessionView ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function SessionViewChoices({
+ busyChoice,
+ disabled,
+ onChoice
+}: {
+ busyChoice: MobileOnboardingBusyChoice
+ disabled: boolean
+ onChoice: (view: MobileSessionView) => void
+}) {
+ return (
+ <>
+ onChoice('chat')}
+ />
+ onChoice('terminal')}
+ />
+ >
+ )
+}
+
+function NotificationChoices({
+ busyChoice,
+ disabled,
+ onChoice
+}: {
+ busyChoice: MobileOnboardingBusyChoice
+ disabled: boolean
+ onChoice: (choice: NotificationOnboardingChoice) => void
+}) {
+ return (
+ <>
+ onChoice('enable')}
+ />
+ onChoice('skip')}
+ />
+ >
+ )
+}
+
+function ChoiceButton({
+ label,
+ accessibilityLabel,
+ primary = false,
+ busy,
+ disabled,
+ onPress
+}: {
+ label: string
+ accessibilityLabel?: string
+ primary?: boolean
+ busy: boolean
+ disabled: boolean
+ onPress: () => void
+}) {
+ return (
+ [
+ primary ? styles.primaryButton : styles.secondaryButton,
+ pressed && styles.buttonPressed,
+ disabled && styles.buttonDisabled
+ ]}
+ onPress={onPress}
+ >
+ {busy ? (
+
+ ) : (
+ {label}
+ )}
+
+ )
+}
diff --git a/mobile/src/onboarding/mobile-onboarding-plan.test.ts b/mobile/src/onboarding/mobile-onboarding-plan.test.ts
new file mode 100644
index 00000000000..d04396bdef7
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-plan.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate'
+import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
+import {
+ loadMobileOnboardingSteps,
+ mobileOnboardingDestination,
+ parseMobileOnboardingSteps
+} from './mobile-onboarding-plan'
+
+vi.mock('../notifications/notification-opt-in-gate', () => ({
+ shouldPresentNotificationOptIn: vi.fn()
+}))
+vi.mock('../session/session-view-opt-in-gate', () => ({
+ shouldPresentSessionViewOptIn: vi.fn()
+}))
+
+describe('mobile onboarding plan', () => {
+ beforeEach(() => {
+ vi.mocked(shouldPresentNotificationOptIn).mockReset().mockResolvedValue(false)
+ vi.mocked(shouldPresentSessionViewOptIn).mockReset().mockResolvedValue(false)
+ })
+
+ it.each([
+ [true, true, ['session-view', 'notifications']],
+ [true, false, ['session-view']],
+ [false, true, ['notifications']],
+ [false, false, []]
+ ] as const)(
+ 'builds the exact plan for session=%s notifications=%s',
+ async (showSession, showNotifications, expected) => {
+ vi.mocked(shouldPresentSessionViewOptIn).mockResolvedValue(showSession)
+ vi.mocked(shouldPresentNotificationOptIn).mockResolvedValue(showNotifications)
+
+ await expect(loadMobileOnboardingSteps()).resolves.toEqual(expected)
+ expect(shouldPresentSessionViewOptIn).toHaveBeenCalledOnce()
+ expect(shouldPresentNotificationOptIn).toHaveBeenCalledOnce()
+ }
+ )
+
+ it.each([
+ [[], undefined, '/'],
+ [[], 'paired-host', '/h/paired-host'],
+ [
+ ['session-view', 'notifications'],
+ undefined,
+ {
+ pathname: '/mobile-onboarding',
+ params: { steps: 'session-view,notifications' }
+ }
+ ],
+ [
+ ['notifications'],
+ 'paired-host',
+ {
+ pathname: '/mobile-onboarding',
+ params: { steps: 'notifications', hostId: 'paired-host' }
+ }
+ ]
+ ] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => {
+ expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination)
+ })
+
+ it('parses route steps in canonical order without duplicates', () => {
+ expect(parseMobileOnboardingSteps('notifications,unknown,session-view,notifications')).toEqual([
+ 'session-view',
+ 'notifications'
+ ])
+ expect(parseMobileOnboardingSteps('notifications')).toEqual(['notifications'])
+ })
+
+ it('defaults missing or invalid route state to the complete wizard', () => {
+ expect(parseMobileOnboardingSteps(undefined)).toEqual(['session-view', 'notifications'])
+ expect(parseMobileOnboardingSteps('unknown')).toEqual(['session-view', 'notifications'])
+ })
+})
diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts
new file mode 100644
index 00000000000..3435034c467
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-plan.ts
@@ -0,0 +1,51 @@
+import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate'
+import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
+
+export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const
+export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number]
+export type MobileOnboardingDestination =
+ | '/'
+ | `/h/${string}`
+ | {
+ pathname: '/mobile-onboarding'
+ params: { steps: string; hostId?: string }
+ }
+
+/** Loads every outstanding decision in the order the wizard presents them. */
+export async function loadMobileOnboardingSteps(): Promise {
+ // Why: the wizard needs the complete plan for accurate progress dots; run the
+ // independent gates together so adding the second decision does not add latency.
+ const [showSessionView, showNotifications] = await Promise.all([
+ shouldPresentSessionViewOptIn(),
+ shouldPresentNotificationOptIn()
+ ])
+ return MOBILE_ONBOARDING_STEPS.filter(
+ (step) =>
+ (step === 'session-view' && showSessionView) ||
+ (step === 'notifications' && showNotifications)
+ )
+}
+
+/** Preserves a paired host while routing through outstanding decisions. */
+export function mobileOnboardingDestination(
+ steps: readonly MobileOnboardingStep[],
+ hostId?: string
+): MobileOnboardingDestination {
+ if (steps.length === 0) {
+ return hostId ? `/h/${hostId}` : '/'
+ }
+ return {
+ pathname: '/mobile-onboarding',
+ params: { steps: steps.join(','), ...(hostId ? { hostId } : {}) }
+ }
+}
+
+/** Restores the canonical order and ignores duplicate or unknown route values. */
+export function parseMobileOnboardingSteps(raw: string | undefined): MobileOnboardingStep[] {
+ if (!raw) {
+ return [...MOBILE_ONBOARDING_STEPS]
+ }
+ const requested = new Set(raw.split(','))
+ const steps = MOBILE_ONBOARDING_STEPS.filter((step) => requested.has(step))
+ return steps.length > 0 ? steps : [...MOBILE_ONBOARDING_STEPS]
+}
diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts
new file mode 100644
index 00000000000..d571ce28a01
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts
@@ -0,0 +1,165 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import MobileOnboardingScreen from '../../app/mobile-onboarding'
+
+const mocks = vi.hoisted(() => ({
+ params: { hostId: 'paired-host', steps: 'session-view,notifications' },
+ replace: vi.fn(),
+ reducedMotionEnabled: false,
+ animatedTiming: vi.fn(),
+ ensureNotificationPermissions: vi.fn(),
+ saveDefaultSessionView: vi.fn(),
+ savePushNotificationsEnabled: vi.fn()
+}))
+
+vi.mock('react-native', () => ({
+ AccessibilityInfo: {
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
+ isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled))
+ },
+ Animated: {
+ Value: class {},
+ View: 'AnimatedView',
+ multiply: vi.fn(() => 0),
+ timing: mocks.animatedTiming
+ },
+ BackHandler: {
+ addEventListener: vi.fn(() => ({ remove: vi.fn() }))
+ },
+ StyleSheet: { create: (styles: unknown) => styles },
+ Text: 'Text',
+ View: 'View',
+ useWindowDimensions: () => ({ width: 390, height: 844 })
+}))
+
+vi.mock('expo-router', () => ({
+ useFocusEffect: vi.fn(),
+ useLocalSearchParams: () => mocks.params,
+ useRouter: () => ({ replace: mocks.replace })
+}))
+
+vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
+vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' }))
+vi.mock('./MobileOnboardingPage', () => ({ MobileOnboardingPage: 'MobileOnboardingPage' }))
+vi.mock('../notifications/mobile-notifications', () => ({
+ ensureNotificationPermissions: mocks.ensureNotificationPermissions
+}))
+vi.mock('../storage/session-view-preferences', () => ({
+ saveDefaultSessionView: mocks.saveDefaultSessionView
+}))
+vi.mock('../storage/preferences', () => ({
+ savePushNotificationsEnabled: mocks.savePushNotificationsEnabled
+}))
+
+describe('MobileOnboardingScreen', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
+ mocks.replace.mockReset()
+ mocks.reducedMotionEnabled = false
+ mocks.animatedTiming.mockReset().mockReturnValue({
+ start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true })
+ })
+ mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true)
+ mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined)
+ mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ vi.restoreAllMocks()
+ })
+
+ async function renderScreen() {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
+ throw new Error(String(args[0]))
+ }
+ })
+ await act(async () => {
+ renderer = create(createElement(MobileOnboardingScreen))
+ })
+ consoleError.mockRestore()
+ }
+
+ function pages() {
+ return renderer!.root.findAllByType('MobileOnboardingPage')
+ }
+
+ it('advances from session view to notifications before opening the paired host', async () => {
+ await renderScreen()
+ expect(pages().map((page) => page.props.active)).toEqual([true, false])
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(mocks.saveDefaultSessionView).toHaveBeenCalledWith('chat')
+ expect(pages().map((page) => page.props.active)).toEqual([false, true])
+ expect(mocks.replace).not.toHaveBeenCalled()
+
+ await act(async () => pages()[1].props.onNotificationChoice('skip'))
+ expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
+ expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false)
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('finishes immediately when the plan contains only one outstanding step', async () => {
+ mocks.params = { hostId: 'paired-host', steps: 'session-view' }
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('terminal'))
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('keeps the current step retryable when persistence fails', async () => {
+ mocks.params = { hostId: 'paired-host', steps: 'session-view' }
+ mocks.saveDefaultSessionView
+ .mockRejectedValueOnce(new Error('storage unavailable'))
+ .mockResolvedValueOnce(undefined)
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(pages()[0].props.error).toBe('Your choice could not be saved. Try again.')
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(mocks.saveDefaultSessionView).toHaveBeenCalledTimes(2)
+ expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host')
+ })
+
+ it('resets carousel state when the route supplies a new onboarding plan', async () => {
+ await renderScreen()
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+ expect(pages().map((page) => page.props.active)).toEqual([false, true])
+
+ mocks.params = { hostId: 'paired-host', steps: 'notifications' }
+ await act(async () => renderer!.update(createElement(MobileOnboardingScreen)))
+
+ expect(pages()).toHaveLength(1)
+ expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true })
+ })
+
+ it('skips the slide animation when the device requests reduced motion', async () => {
+ mocks.reducedMotionEnabled = true
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+
+ expect(mocks.animatedTiming).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ duration: 0, useNativeDriver: true })
+ )
+ })
+
+ it('keeps the next decision available if the cosmetic transition is interrupted', async () => {
+ mocks.animatedTiming.mockReturnValue({
+ start: (callback: (result: { finished: boolean }) => void) => callback({ finished: false })
+ })
+ await renderScreen()
+
+ await act(async () => pages()[0].props.onSessionChoice('chat'))
+
+ expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null })
+ })
+})
diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts
new file mode 100644
index 00000000000..20f36ec0f5b
--- /dev/null
+++ b/mobile/src/onboarding/mobile-onboarding-styles.ts
@@ -0,0 +1,132 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const mobileOnboardingStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ brandRow: {
+ minHeight: 52,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.xl
+ },
+ brandName: {
+ color: colors.textPrimary,
+ fontSize: 17,
+ fontWeight: '700'
+ },
+ progress: {
+ position: 'absolute',
+ left: '50%',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 7,
+ transform: [{ translateX: -18 }]
+ },
+ progressDot: {
+ width: 7,
+ height: 7,
+ borderRadius: 999,
+ backgroundColor: colors.borderSubtle
+ },
+ progressDotActive: {
+ width: 22,
+ backgroundColor: colors.textPrimary
+ },
+ carouselViewport: {
+ flex: 1,
+ overflow: 'hidden'
+ },
+ carouselTrack: {
+ height: '100%',
+ flexDirection: 'row'
+ },
+ page: {
+ height: '100%'
+ },
+ // Why: every decision remains reachable in landscape and with accessibility
+ // text scaling even though Back and swipe-to-skip are intentionally disabled.
+ pageContent: {
+ flexGrow: 1,
+ paddingHorizontal: spacing.xl
+ },
+ content: {
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.xl
+ },
+ iconSurface: {
+ width: 64,
+ height: 64,
+ borderRadius: radii.card,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised,
+ marginBottom: spacing.xl
+ },
+ title: {
+ maxWidth: 420,
+ color: colors.textPrimary,
+ fontSize: 26,
+ fontWeight: '700',
+ letterSpacing: -0.3,
+ textAlign: 'center'
+ },
+ body: {
+ maxWidth: 420,
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ lineHeight: 21,
+ textAlign: 'center',
+ marginTop: spacing.md
+ },
+ footer: {
+ width: '100%',
+ maxWidth: 420,
+ alignSelf: 'center',
+ paddingBottom: spacing.lg
+ },
+ primaryButton: {
+ minHeight: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.surfaceBright,
+ paddingVertical: spacing.sm
+ },
+ primaryButtonText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ secondaryButton: {
+ minHeight: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ marginTop: spacing.xs,
+ paddingVertical: spacing.sm
+ },
+ secondaryButtonText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontWeight: '500'
+ },
+ buttonPressed: {
+ opacity: 0.72
+ },
+ buttonDisabled: {
+ opacity: 0.58
+ },
+ error: {
+ color: colors.statusRed,
+ fontSize: typography.metaSize,
+ lineHeight: 18,
+ textAlign: 'center',
+ marginBottom: spacing.sm
+ }
+})
diff --git a/mobile/src/session/QuickCommandEditorForm.tsx b/mobile/src/session/QuickCommandEditorForm.tsx
new file mode 100644
index 00000000000..171bf8ef2ce
--- /dev/null
+++ b/mobile/src/session/QuickCommandEditorForm.tsx
@@ -0,0 +1,317 @@
+import { useState } from 'react'
+import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
+import { ChevronDown, ChevronRight } from 'lucide-react-native'
+import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+import { MobileAgentIcon } from '../components/MobileAgentIcon'
+import {
+ getQuickCommandAgentLabel,
+ MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
+ MAX_QUICK_COMMAND_LABEL_LENGTH,
+ MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
+} from '../terminal/quick-commands'
+import type { QuickCommandDraft } from './quick-command-draft'
+import { isQuickCommandDraftValid } from './quick-command-draft'
+
+type Props = {
+ draft: QuickCommandDraft
+ mode: 'add' | 'edit'
+ saving: boolean
+ error: string | null
+ // A mobile session lives in one repo, so "Project" scope means the current
+ // worktree's repo — no cross-repo picker like desktop.
+ repoId: string | null
+ repoName: string | null
+ onChange: (patch: Partial) => void
+ onOpenAgentPicker: () => void
+ onCancel: () => void
+ onSave: () => void
+}
+
+function ActionToggle({
+ value,
+ onChange
+}: {
+ value: QuickCommandDraft['action']
+ onChange: (action: QuickCommandDraft['action']) => void
+}) {
+ return (
+
+ {(['terminal-command', 'agent-prompt'] as const).map((action) => {
+ const selected = value === action
+ return (
+ [
+ styles.toggleItem,
+ selected && styles.toggleItemSelected,
+ pressed && !selected && styles.pressed
+ ]}
+ onPress={() => onChange(action)}
+ accessibilityRole="button"
+ accessibilityState={{ selected }}
+ >
+
+ {action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'}
+
+
+ )
+ })}
+
+ )
+}
+
+export function QuickCommandEditorForm({
+ draft,
+ mode,
+ saving,
+ error,
+ repoId,
+ repoName,
+ onChange,
+ onOpenAgentPicker,
+ onCancel,
+ onSave
+}: Props) {
+ const hasRepoScope = repoId !== null
+ const [advancedOpen, setAdvancedOpen] = useState(draft.scope.type === 'repo')
+ const isAgent = draft.action === 'agent-prompt'
+ const canSave = isQuickCommandDraftValid(draft) && !saving
+
+ return (
+
+
+ Label
+ onChange({ label })}
+ placeholder="Start dev server"
+ placeholderTextColor={colors.textMuted}
+ autoCapitalize="none"
+ autoCorrect={false}
+ maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH}
+ />
+
+
+
+ Action
+ onChange({ action })} />
+
+
+ {isAgent ? (
+
+ Agent
+ [styles.select, pressed && styles.pressed]}
+ onPress={onOpenAgentPicker}
+ accessibilityRole="button"
+ >
+ {draft.agent ? (
+
+
+ {getQuickCommandAgentLabel(draft.agent)}
+
+ ) : (
+ Choose agent
+ )}
+
+
+
+ ) : null}
+
+
+ {isAgent ? 'Prompt' : 'Command Text'}
+ onChange(isAgent ? { prompt: text } : { command: text })}
+ placeholder={isAgent ? 'Ask the agent to investigate this workspace' : 'npm run dev'}
+ placeholderTextColor={colors.textMuted}
+ autoCapitalize="none"
+ autoCorrect={false}
+ multiline
+ maxLength={
+ isAgent ? MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH : MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
+ }
+ />
+ {isAgent ? (
+ Supports skills, file paths, and built-in commands.
+ ) : null}
+
+
+
+ [styles.advancedToggle, pressed && styles.pressed]}
+ onPress={() => setAdvancedOpen((open) => !open)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: advancedOpen }}
+ >
+ {advancedOpen ? (
+
+ ) : (
+
+ )}
+ Advanced
+
+
+ {advancedOpen ? (
+
+ {!isAgent ? (
+
+
+ Append Enter
+
+ Submit immediately instead of only inserting text.
+
+
+ onChange({ appendEnter })}
+ trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
+ thumbColor={colors.surfaceBright}
+ />
+
+ ) : null}
+
+
+ Scope
+
+ {(['global', 'repo'] as const).map((scopeType) => {
+ const selected = draft.scope.type === scopeType
+ const disabled = scopeType === 'repo' && !hasRepoScope
+ return (
+ [
+ styles.toggleItem,
+ selected && styles.toggleItemSelected,
+ disabled && styles.toggleItemDisabled,
+ pressed && !selected && !disabled && styles.pressed
+ ]}
+ onPress={() =>
+ onChange({
+ scope:
+ scopeType === 'repo' && repoId
+ ? { type: 'repo', repoId }
+ : { type: 'global' }
+ })
+ }
+ accessibilityRole="button"
+ accessibilityState={{ selected, disabled }}
+ >
+
+ {scopeType === 'global' ? 'Global' : 'Project'}
+
+
+ )
+ })}
+
+ {draft.scope.type === 'repo' && repoName ? (
+ {repoName}
+ ) : null}
+
+
+ ) : null}
+
+
+ {error ? {error} : null}
+
+
+ [styles.button, styles.cancelButton, pressed && styles.pressed]}
+ onPress={onCancel}
+ accessibilityRole="button"
+ >
+ Cancel
+
+
+
+ {mode === 'edit' ? 'Save' : 'Add Quick Command'}
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ form: { gap: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm },
+ field: { gap: spacing.sm },
+ label: { fontSize: 12, fontWeight: '600', color: colors.textSecondary },
+ input: {
+ backgroundColor: colors.bgPanel,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm + 2,
+ fontSize: 14,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ textarea: { minHeight: 92, textAlignVertical: 'top' },
+ mono: { fontFamily: typography.monoFamily },
+ hint: { fontSize: 12, color: colors.textMuted },
+ error: { fontSize: 13, color: colors.statusRed, marginTop: spacing.xs },
+ pressed: { backgroundColor: colors.bgRaised },
+ toggleGroup: { flexDirection: 'row', gap: spacing.sm },
+ toggleItem: {
+ flex: 1,
+ height: 40,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ toggleItemSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textMuted },
+ toggleItemDisabled: { opacity: 0.4 },
+ toggleText: { fontSize: 13, fontWeight: '500', color: colors.textSecondary },
+ toggleTextSelected: { color: colors.textPrimary },
+ select: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.input,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm + 2
+ },
+ selectValue: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ selectValueText: { fontSize: 14, color: colors.textPrimary },
+ selectPlaceholder: { fontSize: 14, color: colors.textMuted },
+ scopeRepoName: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ fontFamily: typography.monoFamily,
+ paddingHorizontal: spacing.xs
+ },
+ advancedToggle: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingVertical: spacing.xs
+ },
+ advancedText: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
+ advancedBody: { gap: spacing.md, paddingTop: spacing.xs },
+ switchRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
+ switchText: { flex: 1 },
+ switchTitle: { fontSize: 14, color: colors.textPrimary },
+ switchDesc: { fontSize: 12, color: colors.textMuted, marginTop: 1 },
+ footer: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
+ button: { flex: 1, borderRadius: 8, paddingVertical: spacing.md, alignItems: 'center' },
+ cancelButton: { borderWidth: 1, borderColor: colors.borderSubtle },
+ cancelText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
+ saveButton: { backgroundColor: colors.textPrimary },
+ saveButtonDisabled: { backgroundColor: colors.bgRaised },
+ saveText: { fontSize: 14, fontWeight: '700', color: colors.bgBase },
+ saveTextDisabled: { color: colors.textMuted }
+})
diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts
new file mode 100644
index 00000000000..eb2e2c787c0
--- /dev/null
+++ b/mobile/src/session/QuickCommandsList.test.ts
@@ -0,0 +1,90 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH, QuickCommandsList } from './QuickCommandsList'
+
+vi.mock('react-native', () => ({
+ ActivityIndicator: 'ActivityIndicator',
+ Pressable: 'Pressable',
+ StyleSheet: {
+ create: (styles: T) => styles,
+ hairlineWidth: 1
+ },
+ Text: 'Text',
+ TextInput: 'TextInput',
+ View: 'View'
+}))
+
+vi.mock('lucide-react-native', () => ({
+ Check: 'Check',
+ Pencil: 'Pencil',
+ Play: 'Play',
+ Plus: 'Plus',
+ Search: 'Search',
+ Trash2: 'Trash2'
+}))
+
+vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
+
+describe('QuickCommandsList search', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ it('keeps an active filter clearable when only one command remains', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsList, {
+ repoCommands: [],
+ globalCommands: [],
+ totalCount: 1,
+ query: 'missing',
+ loading: false,
+ disabled: false,
+ canAdd: true,
+ error: null,
+ onQueryChange: vi.fn(),
+ onLaunch: vi.fn(),
+ onEdit: vi.fn(),
+ onDelete: vi.fn(),
+ onAdd: vi.fn()
+ })
+ )
+ })
+
+ const search = renderer!.root.findByType('TextInput')
+ expect(search.props.value).toBe('missing')
+ expect(search.props.maxLength).toBe(QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH)
+ })
+
+ it('omits idle search chrome for a single command', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsList, {
+ repoCommands: [],
+ globalCommands: [],
+ totalCount: 1,
+ query: '',
+ loading: false,
+ disabled: false,
+ canAdd: true,
+ error: null,
+ onQueryChange: vi.fn(),
+ onLaunch: vi.fn(),
+ onEdit: vi.fn(),
+ onDelete: vi.fn(),
+ onAdd: vi.fn()
+ })
+ )
+ })
+
+ expect(renderer!.root.findAllByType('TextInput')).toHaveLength(0)
+ })
+})
diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx
new file mode 100644
index 00000000000..2c76884d335
--- /dev/null
+++ b/mobile/src/session/QuickCommandsList.tsx
@@ -0,0 +1,332 @@
+import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
+import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import { MobileAgentIcon } from '../components/MobileAgentIcon'
+import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
+import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
+import {
+ getQuickCommandDisplayPreview,
+ isAgentQuickCommand,
+ supportsTerminalAgentQuickCommand
+} from '../terminal/quick-commands'
+
+export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
+ supportsTerminalAgentQuickCommand(entry.id)
+)
+
+export const QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH = 2048
+
+type ListProps = {
+ repoCommands: TerminalQuickCommand[]
+ globalCommands: TerminalQuickCommand[]
+ totalCount: number
+ query: string
+ loading: boolean
+ disabled: boolean
+ canAdd: boolean
+ error: string | null
+ onQueryChange: (value: string) => void
+ onLaunch: (command: TerminalQuickCommand) => void
+ onEdit: (command: TerminalQuickCommand) => void
+ onDelete: (command: TerminalQuickCommand) => void
+ onAdd: () => void
+}
+
+export function QuickCommandsList({
+ repoCommands,
+ globalCommands,
+ totalCount,
+ query,
+ loading,
+ disabled,
+ canAdd,
+ error,
+ onQueryChange,
+ onLaunch,
+ onEdit,
+ onDelete,
+ onAdd
+}: ListProps) {
+ const hasVisible = repoCommands.length + globalCommands.length > 0
+ const addDisabled = disabled || !canAdd
+ // Why: keep an active filter clearable if a delete or paired desktop edit
+ // leaves only one command while the sheet is open.
+ const showSearch = totalCount > 1 || query.length > 0
+ return (
+
+ {showSearch ? (
+
+
+
+
+ ) : null}
+
+ {error ? {error} : null}
+
+ {loading && !hasVisible ? (
+
+ ) : null}
+
+ {!loading && totalCount === 0 ? (
+ No quick commands yet.
+ ) : null}
+
+ {!loading && totalCount > 0 && !hasVisible ? (
+ No matching quick commands.
+ ) : null}
+
+ {repoCommands.length > 0 ? (
+
+ ) : null}
+
+ {globalCommands.length > 0 ? (
+
+ ) : null}
+
+ [
+ styles.addRow,
+ addDisabled && styles.disabled,
+ pressed && !addDisabled && styles.pressed
+ ]}
+ disabled={addDisabled}
+ onPress={onAdd}
+ accessibilityRole="button"
+ >
+
+
+ {canAdd ? 'New quick command' : 'Quick command limit reached'}
+
+
+
+ )
+}
+
+function QuickCommandGroup({
+ label,
+ commands,
+ onLaunch,
+ onEdit,
+ onDelete,
+ disabled
+}: {
+ label: string
+ commands: TerminalQuickCommand[]
+ onLaunch: (command: TerminalQuickCommand) => void
+ onEdit: (command: TerminalQuickCommand) => void
+ onDelete: (command: TerminalQuickCommand) => void
+ disabled: boolean
+}) {
+ return (
+
+ {label}
+
+ {commands.map((command, index) => (
+
+ ))}
+
+
+ )
+}
+
+function QuickCommandRow({
+ command,
+ first,
+ onLaunch,
+ onEdit,
+ onDelete,
+ disabled
+}: {
+ command: TerminalQuickCommand
+ first: boolean
+ onLaunch: (command: TerminalQuickCommand) => void
+ onEdit: (command: TerminalQuickCommand) => void
+ onDelete: (command: TerminalQuickCommand) => void
+ disabled: boolean
+}) {
+ const isAgent = isAgentQuickCommand(command)
+ return (
+
+ [styles.rowMain, pressed && !disabled && styles.pressed]}
+ disabled={disabled}
+ onPress={() => onLaunch(command)}
+ accessibilityRole="button"
+ accessibilityLabel={`Run ${command.label}`}
+ >
+
+ {isAgent ? (
+
+ ) : (
+
+ )}
+
+
+
+ {command.label}
+
+
+ {getQuickCommandDisplayPreview(command)}
+
+
+
+ [styles.rowAction, pressed && !disabled && styles.pressed]}
+ disabled={disabled}
+ onPress={() => onEdit(command)}
+ accessibilityLabel={`Edit ${command.label}`}
+ >
+
+
+ [styles.rowAction, pressed && !disabled && styles.pressed]}
+ disabled={disabled}
+ onPress={() => onDelete(command)}
+ accessibilityLabel={`Delete ${command.label}`}
+ >
+
+
+
+ )
+}
+
+export function QuickCommandAgentPicker({
+ selected,
+ onSelect
+}: {
+ selected: TuiAgent | null
+ onSelect: (agent: TuiAgent) => void
+}) {
+ return (
+
+ {QUICK_COMMAND_SUPPORTED_AGENTS.map((agent, index) => (
+ [
+ styles.row,
+ index > 0 && styles.rowBorder,
+ pressed && styles.pressed
+ ]}
+ onPress={() => onSelect(agent.id)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: selected === agent.id }}
+ >
+
+
+
+ {agent.label}
+ {selected === agent.id ? : null}
+
+ ))}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ pressed: { backgroundColor: colors.bgRaised },
+ disabled: { opacity: 0.45 },
+ listBody: { gap: spacing.sm, paddingBottom: spacing.sm },
+ search: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ backgroundColor: colors.bgPanel,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm
+ },
+ searchInput: { flex: 1, color: colors.textPrimary, fontSize: 14, padding: 0 },
+ error: { color: colors.statusRed, fontSize: 13, paddingHorizontal: spacing.xs },
+ loading: { paddingVertical: spacing.lg },
+ empty: {
+ color: colors.textMuted,
+ fontSize: 14,
+ textAlign: 'center',
+ paddingVertical: spacing.lg
+ },
+ groupLabel: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.6,
+ paddingHorizontal: spacing.xs,
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.xs
+ },
+ group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' },
+ row: { flexDirection: 'row', alignItems: 'center' },
+ rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
+ rowMain: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ paddingVertical: spacing.md,
+ paddingLeft: spacing.md,
+ minWidth: 0
+ },
+ rowIcon: {
+ width: 26,
+ height: 26,
+ borderRadius: 6,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ rowText: { flex: 1, minWidth: 0 },
+ rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
+ rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
+ mono: { fontFamily: typography.monoFamily },
+ rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' },
+ agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary },
+ addRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderStyle: 'dashed',
+ borderColor: colors.borderSubtle,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md,
+ marginTop: spacing.xs
+ },
+ addText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }
+})
diff --git a/mobile/src/session/QuickCommandsSheet.test.ts b/mobile/src/session/QuickCommandsSheet.test.ts
new file mode 100644
index 00000000000..654882cd2ad
--- /dev/null
+++ b/mobile/src/session/QuickCommandsSheet.test.ts
@@ -0,0 +1,225 @@
+import { createElement, type ReactNode } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import type { TerminalQuickCommand } from '../../../src/shared/types'
+import type { RpcClient } from '../transport/rpc-client'
+import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
+import { QuickCommandsSheet } from './QuickCommandsSheet'
+
+const mocks = vi.hoisted(() => ({
+ alert: vi.fn(),
+ commands: [] as TerminalQuickCommand[],
+ persist: vi.fn()
+}))
+const quickCommandEditorForm = 'QuickCommandEditorForm'
+const quickCommandsList = 'QuickCommandsList'
+const command: TerminalQuickCommand = {
+ id: 'command',
+ label: 'Test',
+ action: 'terminal-command',
+ command: 'pnpm test',
+ appendEnter: true,
+ scope: { type: 'global' }
+}
+
+vi.mock('react-native', () => ({
+ Alert: { alert: mocks.alert },
+ Pressable: 'Pressable',
+ StyleSheet: { create: (styles: T) => styles },
+ Text: 'Text',
+ View: 'View'
+}))
+
+vi.mock('lucide-react-native', () => ({ ChevronLeft: 'ChevronLeft' }))
+
+vi.mock('../components/BottomDrawer', () => ({
+ BottomDrawer: ({ children }: { children: ReactNode }) => children
+}))
+
+vi.mock('./QuickCommandEditorForm', () => ({
+ QuickCommandEditorForm: 'QuickCommandEditorForm'
+}))
+
+vi.mock('./QuickCommandsList', () => ({
+ QuickCommandAgentPicker: 'QuickCommandAgentPicker',
+ QuickCommandsList: 'QuickCommandsList'
+}))
+
+vi.mock('./use-quick-commands', () => ({
+ useQuickCommands: () => ({
+ commands: mocks.commands,
+ loading: false,
+ ready: true,
+ error: null,
+ persist: mocks.persist
+ })
+}))
+
+function deferred() {
+ let resolve: (value: T) => void = () => {}
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+describe('QuickCommandsSheet', () => {
+ let renderer: ReactTestRenderer | null = null
+ let consoleSpy: MockInstance
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ mocks.alert.mockReset()
+ mocks.commands = []
+ mocks.persist.mockReset()
+ const originalConsoleError = console.error
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ consoleSpy.mockRestore()
+ })
+
+ it('keeps the sheet open when a launch is rejected', async () => {
+ const onClose = vi.fn()
+ const onLaunch = vi.fn(() => false)
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsSheet, {
+ visible: true,
+ onClose,
+ client: {} as RpcClient,
+ repoId: 'repo-1',
+ repoName: 'Repo',
+ onLaunch
+ })
+ )
+ })
+
+ act(() => renderer!.root.findByType(quickCommandsList).props.onLaunch(command))
+
+ expect(onLaunch).toHaveBeenCalledWith(command)
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('submits one full-list mutation for a same-frame double tap', async () => {
+ const save = deferred()
+ mocks.persist.mockReturnValue(save.promise)
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsSheet, {
+ visible: true,
+ onClose: vi.fn(),
+ client: {} as RpcClient,
+ repoId: 'repo-1',
+ repoName: 'Repo',
+ onLaunch: () => true
+ })
+ )
+ })
+
+ act(() => renderer!.root.findByType(quickCommandsList).props.onAdd())
+ const editor = renderer!.root.findByType(quickCommandEditorForm)
+ act(() => {
+ editor.props.onChange({ label: 'Test' })
+ editor.props.onChange({ command: 'pnpm test' })
+ })
+ const readyEditor = renderer!.root.findByType(quickCommandEditorForm)
+ act(() => {
+ readyEditor.props.onSave()
+ readyEditor.props.onSave()
+ })
+
+ expect(mocks.persist).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ save.resolve(true)
+ await save.promise
+ })
+ })
+
+ it('keeps creation closed when the host command limit is reached', async () => {
+ mocks.commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({
+ ...command,
+ id: `command-${index}`
+ }))
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsSheet, {
+ visible: true,
+ onClose: vi.fn(),
+ client: {} as RpcClient,
+ repoId: 'repo-1',
+ repoName: 'Repo',
+ onLaunch: () => true
+ })
+ )
+ })
+
+ const list = renderer!.root.findByType(quickCommandsList)
+ expect(list.props.canAdd).toBe(false)
+ act(() => list.props.onAdd())
+ expect(renderer!.root.findAllByType(quickCommandEditorForm)).toHaveLength(0)
+ })
+
+ it('shows only global commands and defaults new commands to global without a repo', async () => {
+ const repoCommand: TerminalQuickCommand = {
+ ...command,
+ id: 'repo-command',
+ scope: { type: 'repo', repoId: 'repo-1' }
+ }
+ mocks.commands = [command, repoCommand]
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsSheet, {
+ visible: true,
+ onClose: vi.fn(),
+ client: {} as RpcClient,
+ repoId: null,
+ repoName: 'Folder workspace',
+ onLaunch: () => true
+ })
+ )
+ })
+
+ const list = renderer!.root.findByType(quickCommandsList)
+ expect(list.props.globalCommands).toEqual([command])
+ expect(list.props.repoCommands).toEqual([])
+ act(() => list.props.onAdd())
+ expect(renderer!.root.findByType(quickCommandEditorForm).props.draft.scope).toEqual({
+ type: 'global'
+ })
+ })
+
+ it('does not delete a shared command until the destructive action is confirmed', async () => {
+ await act(async () => {
+ renderer = create(
+ createElement(QuickCommandsSheet, {
+ visible: true,
+ onClose: vi.fn(),
+ client: {} as RpcClient,
+ repoId: 'repo-1',
+ repoName: 'Repo',
+ onLaunch: () => true
+ })
+ )
+ })
+
+ act(() => renderer!.root.findByType(quickCommandsList).props.onDelete(command))
+ expect(mocks.persist).not.toHaveBeenCalled()
+
+ const actions = mocks.alert.mock.calls[0]?.[2] as
+ | Array<{ style?: string; onPress?: () => void }>
+ | undefined
+ act(() => actions?.find((action) => action.style === 'destructive')?.onPress?.())
+
+ expect(mocks.persist).toHaveBeenCalledTimes(1)
+ expect(mocks.persist).toHaveBeenCalledWith({ type: 'delete', id: command.id })
+ })
+})
diff --git a/mobile/src/session/QuickCommandsSheet.tsx b/mobile/src/session/QuickCommandsSheet.tsx
new file mode 100644
index 00000000000..c4dbff9473d
--- /dev/null
+++ b/mobile/src/session/QuickCommandsSheet.tsx
@@ -0,0 +1,251 @@
+import { useMemo, useRef, useState } from 'react'
+import { Alert, View, Text, Pressable, StyleSheet } from 'react-native'
+import { ChevronLeft } from 'lucide-react-native'
+import { colors, spacing } from '../theme/mobile-theme'
+import { BottomDrawer } from '../components/BottomDrawer'
+import type { RpcClient } from '../transport/rpc-client'
+import type { TerminalQuickCommand } from '../../../src/shared/types'
+import {
+ getQuickCommandPreview,
+ MAX_QUICK_COMMANDS,
+ quickCommandMatchesRepo
+} from '../terminal/quick-commands'
+import { useQuickCommands } from './use-quick-commands'
+import { QuickCommandEditorForm } from './QuickCommandEditorForm'
+import { QuickCommandAgentPicker, QuickCommandsList } from './QuickCommandsList'
+import {
+ createEmptyQuickCommandDraft,
+ draftToQuickCommand,
+ quickCommandToDraft,
+ type QuickCommandDraft
+} from './quick-command-draft'
+
+type Props = {
+ visible: boolean
+ onClose: () => void
+ client: RpcClient | null
+ repoId: string | null
+ repoName: string | null
+ onLaunch: (command: TerminalQuickCommand) => boolean
+}
+
+type SheetView = 'list' | 'editor' | 'agent'
+
+export function QuickCommandsSheet({
+ visible,
+ onClose,
+ client,
+ repoId,
+ repoName,
+ onLaunch
+}: Props) {
+ const { commands, loading, ready, error, persist } = useQuickCommands({
+ client,
+ enabled: visible
+ })
+ const [view, setView] = useState('list')
+ const [query, setQuery] = useState('')
+ const [draft, setDraft] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const savingRef = useRef(false)
+
+ const [wasVisible, setWasVisible] = useState(visible)
+ if (visible !== wasVisible) {
+ setWasVisible(visible)
+ if (visible) {
+ setView('list')
+ setQuery('')
+ setDraft(null)
+ }
+ }
+
+ // Why: prompt bodies can total ~240 KB. Lowercase them once per settings
+ // update instead of allocating the same search text on every keystroke.
+ const searchableCommands = useMemo(() => {
+ return commands
+ .filter((command) => quickCommandMatchesRepo(command, repoId))
+ .map((command) => ({
+ command,
+ searchText: `${command.label} ${getQuickCommandPreview(command)}`.toLowerCase()
+ }))
+ }, [commands, repoId])
+
+ const visibleCommands = useMemo(() => {
+ const trimmed = query.trim().toLowerCase()
+ return searchableCommands
+ .filter((entry) => !trimmed || entry.searchText.includes(trimmed))
+ .map((entry) => entry.command)
+ }, [query, searchableCommands])
+
+ const repoCommands = visibleCommands.filter((command) => command.scope?.type === 'repo')
+ const globalCommands = visibleCommands.filter((command) => command.scope?.type !== 'repo')
+
+ const openEditor = (command?: TerminalQuickCommand) => {
+ // Why: the host rejects full-list updates above this cap; existing rows
+ // must remain editable/deletable when creation is no longer possible.
+ if (!command && commands.length >= MAX_QUICK_COMMANDS) {
+ return
+ }
+ setDraft(
+ command
+ ? quickCommandToDraft(command)
+ : createEmptyQuickCommandDraft(repoId ? { type: 'repo', repoId } : { type: 'global' })
+ )
+ setView('editor')
+ }
+
+ const handleLaunch = (command: TerminalQuickCommand) => {
+ if (onLaunch(command)) {
+ onClose()
+ }
+ }
+
+ const handleDelete = (command: TerminalQuickCommand) => {
+ // Why: quick commands sync with desktop, so an accidental one-tap delete
+ // removes shared data rather than only dismissing a local row.
+ Alert.alert(
+ `Delete "${command.label || 'Untitled'}"?`,
+ 'This quick command will be removed from your saved list.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: () => {
+ void persist({ type: 'delete', id: command.id })
+ }
+ }
+ ]
+ )
+ }
+
+ const handleSave = async () => {
+ if (!draft || savingRef.current) {
+ return
+ }
+ const built = draftToQuickCommand(draft)
+ if (!built) {
+ return
+ }
+ // Why: state cannot lock out a second tap until React commits the disabled UI.
+ savingRef.current = true
+ setSaving(true)
+ try {
+ const ok = await persist({ type: 'upsert', command: built })
+ if (ok) {
+ setView('list')
+ setDraft(null)
+ }
+ } finally {
+ savingRef.current = false
+ setSaving(false)
+ }
+ }
+
+ const title =
+ view === 'editor'
+ ? draft?.id
+ ? 'Edit Quick Command'
+ : 'Add Quick Command'
+ : view === 'agent'
+ ? 'Choose Agent'
+ : 'Quick Commands'
+
+ return (
+
+
+ {view === 'list' ? (
+
+ ) : (
+ [styles.backButton, pressed && styles.pressed]}
+ onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
+ accessibilityLabel="Back"
+ >
+
+
+ )}
+ {title}
+
+
+
+ {view === 'editor' && draft ? (
+
+
+ Save terminal commands or agent prompts for quick access.
+
+
+ ) : null}
+
+ {view === 'list' ? (
+ openEditor()}
+ />
+ ) : null}
+
+ {view === 'editor' && draft ? (
+
+ setDraft((current) => (current ? { ...current, ...patch } : current))
+ }
+ onOpenAgentPicker={() => setView('agent')}
+ onCancel={() => {
+ setView('list')
+ setDraft(null)
+ }}
+ onSave={() => void handleSave()}
+ />
+ ) : null}
+
+ {view === 'agent' && draft ? (
+ {
+ setDraft((current) => (current ? { ...current, agent } : current))
+ setView('editor')
+ }}
+ />
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ header: { flexDirection: 'row', alignItems: 'center', paddingBottom: spacing.sm },
+ backButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ backSpacer: { width: 30 },
+ title: {
+ flex: 1,
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ textAlign: 'center'
+ },
+ pressed: { backgroundColor: colors.bgRaised },
+ editorDesc: { paddingHorizontal: spacing.xs, paddingBottom: spacing.sm },
+ descText: { fontSize: 12, color: colors.textMuted }
+})
diff --git a/mobile/src/session/quick-command-draft.ts b/mobile/src/session/quick-command-draft.ts
new file mode 100644
index 00000000000..2afdcf74620
--- /dev/null
+++ b/mobile/src/session/quick-command-draft.ts
@@ -0,0 +1,116 @@
+import type {
+ TerminalQuickCommand,
+ TerminalQuickCommandAction,
+ TerminalQuickCommandScope,
+ TuiAgent
+} from '../../../src/shared/types'
+import {
+ isAgentQuickCommand,
+ MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
+ MAX_QUICK_COMMAND_LABEL_LENGTH,
+ MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
+ supportsTerminalAgentQuickCommand
+} from '../terminal/quick-commands'
+
+// A single mutable draft covering both quick-command actions. Both the command
+// text and the agent/prompt are kept while editing so toggling Action back and
+// forth doesn't discard what the user already typed (mirrors desktop's draft).
+export type QuickCommandDraft = {
+ id: string | null
+ label: string
+ action: TerminalQuickCommandAction
+ command: string
+ appendEnter: boolean
+ agent: TuiAgent | null
+ prompt: string
+ scope: TerminalQuickCommandScope
+}
+
+export function createEmptyQuickCommandDraft(scope: TerminalQuickCommandScope): QuickCommandDraft {
+ return {
+ id: null,
+ label: '',
+ action: 'terminal-command',
+ command: '',
+ appendEnter: true,
+ agent: null,
+ prompt: '',
+ scope
+ }
+}
+
+export function quickCommandToDraft(command: TerminalQuickCommand): QuickCommandDraft {
+ const scope: TerminalQuickCommandScope =
+ command.scope?.type === 'repo' && command.scope.repoId
+ ? { type: 'repo', repoId: command.scope.repoId }
+ : { type: 'global' }
+ if (isAgentQuickCommand(command)) {
+ return {
+ id: command.id,
+ label: command.label,
+ action: 'agent-prompt',
+ command: '',
+ appendEnter: true,
+ agent: command.agent,
+ prompt: command.prompt,
+ scope
+ }
+ }
+ return {
+ id: command.id,
+ label: command.label,
+ action: 'terminal-command',
+ command: command.command,
+ appendEnter: command.appendEnter !== false,
+ agent: null,
+ prompt: '',
+ scope
+ }
+}
+
+export function isQuickCommandDraftValid(draft: QuickCommandDraft): boolean {
+ if (!draft.label.trim()) {
+ return false
+ }
+ if (draft.action === 'agent-prompt') {
+ return Boolean(
+ draft.agent &&
+ supportsTerminalAgentQuickCommand(draft.agent) &&
+ draft.prompt.trim().length > 0
+ )
+ }
+ return draft.command.trim().length > 0
+}
+
+// Build the persisted command from a draft. Returns null when incomplete so the
+// caller can keep the editor open. The server re-normalizes, but we trim/cap
+// here so optimistic local state matches what will be saved.
+export function draftToQuickCommand(draft: QuickCommandDraft): TerminalQuickCommand | null {
+ if (!isQuickCommandDraftValid(draft)) {
+ return null
+ }
+ // Why: timestamps alone collide when desktop and mobile add commands in the
+ // same millisecond, which would turn an atomic upsert into an overwrite.
+ const id =
+ draft.id ??
+ `quick-command-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+ const label = draft.label.trim().slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH)
+ if (draft.action === 'agent-prompt' && draft.agent) {
+ return {
+ id,
+ label,
+ action: 'agent-prompt',
+ agent: draft.agent,
+ prompt: draft.prompt.trimEnd().slice(0, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH),
+ scope: draft.scope
+ }
+ }
+ return {
+ id,
+ label,
+ action: 'terminal-command',
+ command: draft.command.trimEnd().slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
+ appendEnter: draft.appendEnter,
+ scope: draft.scope
+ }
+}
diff --git a/mobile/src/session/session-view-opt-in-gate.test.ts b/mobile/src/session/session-view-opt-in-gate.test.ts
new file mode 100644
index 00000000000..8a31f466e8a
--- /dev/null
+++ b/mobile/src/session/session-view-opt-in-gate.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { readDefaultSessionViewPreference } from '../storage/session-view-preferences'
+import { shouldPresentSessionViewOptIn } from './session-view-opt-in-gate'
+
+vi.mock('../storage/session-view-preferences', () => ({
+ readDefaultSessionViewPreference: vi.fn()
+}))
+
+describe('session view opt-in gate', () => {
+ beforeEach(() => {
+ vi.mocked(readDefaultSessionViewPreference).mockReset()
+ })
+
+ it('presents when no default has ever been saved', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: true,
+ hasStoredValue: false
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(true)
+ })
+
+ it.each(['terminal', 'chat'] as const)('preserves an existing %s choice', async (value) => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value,
+ loaded: true,
+ hasStoredValue: true
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+
+ it('does not overwrite an unknown stored value', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: true,
+ hasStoredValue: true
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+
+ it('does not block startup when storage is unreadable', async () => {
+ vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({
+ value: null,
+ loaded: false,
+ hasStoredValue: false
+ })
+ await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false)
+ })
+})
diff --git a/mobile/src/session/session-view-opt-in-gate.ts b/mobile/src/session/session-view-opt-in-gate.ts
new file mode 100644
index 00000000000..b7e6cb1a036
--- /dev/null
+++ b/mobile/src/session/session-view-opt-in-gate.ts
@@ -0,0 +1,10 @@
+import { readDefaultSessionViewPreference } from '../storage/session-view-preferences'
+
+/** Whether to show the one-time screen that lets the user pick terminal vs native
+ * chat. Present it only when the read succeeded and no default was ever saved, so
+ * people who already chose (in the opt-in or Settings) are never prompted again,
+ * and a transient storage failure doesn't trap startup behind the screen. */
+export async function shouldPresentSessionViewOptIn(): Promise {
+ const preference = await readDefaultSessionViewPreference()
+ return preference.loaded && !preference.hasStoredValue
+}
diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
index 752b2ecb3b8..6c97b9e0b04 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
@@ -24,21 +24,17 @@ type PrSidebarControllerInput = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
- // Head branch + SHA come from git.status (`branch`/`head`) via the review screen,
- // not the branchCompare base ref nor worktree metadata (which carries no branch).
+ // branch/headSha come from git.status (not the branchCompare base ref nor worktree metadata, which carries no branch).
branch: string | null
headSha: string | null
}
-// Load options for the shared PR controller. The Source Control hub chip only needs
-// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
-// Pull Request segment is actually open.
+// Load options: the hub chip needs only phase 1 (PR + checks); phase 2 (comments/body) is heavy and waits until the PR segment opens.
export type PrSidebarLoadOptions = {
includeDetails?: boolean
}
-// Identity is worktree + branch only. Head SHA advances on every commit and must not
-// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
+// Identity is worktree + branch only: headSha advances every commit and must not wipe a ready chip to "loading" (soft-refresh handles it).
export function buildMobilePrSidebarIdentity(args: {
worktreeId: string
branch: string | null
@@ -48,33 +44,23 @@ export function buildMobilePrSidebarIdentity(args: {
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
const { client, connState, worktreeId, branch, headSha } = input
- // The dedicated PR icon is available whenever the repo has a GitHub remote —
- // independent of whether the branch has an open PR (a no-PR branch shows an
- // empty state rather than hiding the icon).
+ // PR icon shows for any GitHub remote, regardless of an open PR — a no-PR branch shows an empty state rather than hiding the icon.
const [isGithubRepo, setIsGithubRepo] = useState(false)
- // False until the probe resolves for this worktree. Consumers gate "unavailable
- // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
+ // False until the probe resolves — isGithubRepo=false is meaningless mid-probe, so consumers gate "unavailable" copy on this.
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
const [state, setState] = useState({ kind: 'hidden' })
const [showPRSidebar, setShowPRSidebar] = useState(false)
const loadSeqRef = useRef(0)
- // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
- // phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
+ // Separate seq for phase-2 fetches so they can't cancel a concurrent phase-1 soft refresh (and vice versa).
const detailsSeqRef = useRef(0)
- // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
- // fill-in effect fires as soon as phase 1 renders ready with null details —
- // exactly when load()'s own phase 2 just started. Without this claim, every
- // cold PR-segment open fetched the heavy details payload twice.
+ // (seq, prNumber) of the in-flight phase-2 fetch; without this claim every cold PR-segment open fetched the heavy details twice.
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
const stateIdentityRef = useRef(null)
const stateRef = useRef(state)
stateRef.current = state
const headShaRef = useRef(headSha)
- // Repo eligibility (a GitHub remote) is independent of the branch, so the probe
- // must not require one: a detached HEAD / mid-rebase worktree (branch === null)
- // would otherwise never set repoProbeLoaded, stranding the PR segment on a
- // forever spinner instead of the "Current branch unavailable" state.
+ // Probe is branch-independent (repo eligibility is): requiring a branch would strand a detached-HEAD worktree on a forever spinner.
const probeReady = client !== null && connState === 'connected'
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
@@ -91,9 +77,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
}, [client])
- // Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
- // Worktree change must reset eligibility; a brief disconnect must not — otherwise
- // the hub shows "unavailable for this provider" and hides the chip mid-session.
+ // Probe GitHub-repo eligibility for the icon; a worktree change resets it, a brief disconnect must not (else the chip hides mid-session).
useEffect(() => {
setIsGithubRepo(false)
setRepoProbeLoaded(false)
@@ -112,8 +96,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
})
.catch(() => {
- // Why: sendGithubPrRead already normalizes throws, but a cancelled
- // unmount + any unexpected rejection must not surface as LogBox.
+ // Why: sendGithubPrRead normalizes throws, but a stray rejection on unmount must not surface as LogBox.
if (!cancelled) {
setIsGithubRepo(false)
setRepoProbeLoaded(true)
@@ -133,8 +116,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
return
}
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
- // Why: ready/loading data is scoped to branch. A branch switch must not let
- // the open panel keep rendering the previous PR as "fresh."
+ // Why: data is scoped to branch; a branch switch must not keep rendering the previous PR as "fresh."
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
@@ -152,16 +134,10 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
- // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
- // ownership when its own phase 2 actually starts (the bump below), so a load
- // superseded at the phase-1 guard can never orphan a detailsSeq and silently
- // discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
- // its identity/number/kind guards only let matching details through.
+ // Don't invalidate in-flight phase 2 here: it's only claimed when this load's own phase 2 starts, so a superseded phase-1 load can't orphan the details fetch.
const previousIdentity = stateIdentityRef.current
stateIdentityRef.current = loadIdentity
- // Soft refresh: same branch already showing ready/none stays visible while
- // checks re-fetch (head advanced after commit). Hard loading only on first load
- // or after a real identity wipe.
+ // Soft refresh: same-branch ready/none stays visible while checks re-fetch; hard "loading" only on first load or identity wipe.
const keepVisible =
previousIdentity === loadIdentity &&
(stateRef.current.kind === 'ready' ||
@@ -170,8 +146,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (!keepVisible) {
setState({ kind: 'loading' })
}
- // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
- // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
+ // Phase 1: PR + checks (fast); linkedPR read runs in parallel with forBranch so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (
!shouldApplyResult(seq, loadSeqRef.current) ||
@@ -181,9 +156,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}
stateIdentityRef.current = loadIdentity
- // Keep prior comments/body visible across phase 1 when the same PR is still open.
- // loadPrSidebarData always returns details:null; without this, soft refresh and
- // PR-tab refresh blank the comment tree until phase 2 finishes.
+ // Preserve prior details across phase 1 (loadPrSidebarData returns details:null) so soft/PR-tab refresh doesn't blank the comment tree.
const priorDetails =
next.kind === 'ready' &&
stateRef.current.kind === 'ready' &&
@@ -213,10 +186,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
- // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
- // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
- // bumping detailsSeq, and must not discard the in-flight details it preserved
- // (ensure dedupes against this claim, so nothing would re-fetch them).
+ // Ownership keyed on detailsSeq (not loadSeq): a chip-only soft refresh bumps loadSeq without detailsSeq and must not discard these details.
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
@@ -236,11 +206,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
[buildDeps, branch, headSha, identity, worktreeId]
)
- // Phase-2 only — used when the hub opens the PR segment after a chip-only load.
- // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
- // Retries synthetic placeholders too: a failed phase-2 installs non-null empty
- // details so Description/Comments leave the spinner, and without this ensure
- // would never re-fetch on tab re-open.
+ // Phase-2-only fill-in; uses detailsSeqRef so it can't cancel a concurrent phase-1, and re-fetches non-null placeholders too.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
@@ -252,8 +218,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
return
}
const prNumber = current.data.pr.number
- // A live phase-2 fetch for this PR is already in flight (its claim still owns
- // the latest details seq) — do not start a duplicate.
+ // Skip if a live phase-2 fetch for this PR already owns the latest details seq (dedupe).
const inFlight = detailsInFlightRef.current
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
return
@@ -284,18 +249,13 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
})
}, [buildDeps, identity, worktreeId])
- // Soft-refresh checks when HEAD advances on the same branch (post-commit).
- // Also restarts an in-flight phase-1 load so a mid-flight head advance is not
- // applied with a stale SHA (headShaRef would otherwise advance with no reload).
+ // Soft-refresh on same-branch HEAD advance; restart in-flight load so the advance isn't applied with a stale SHA.
useEffect(() => {
if (headShaRef.current === headSha) {
return
}
headShaRef.current = headSha
- // Identity was just wiped (branch/worktree switch): stateRef still holds the
- // pre-wipe state in this effect flush, which would start a load flavored for
- // the OLD surface (e.g. heavy details on the Changes tab). Let the owning
- // surface's hidden-state effects drive the first load for the new identity.
+ // Identity just wiped: stateRef still holds stale pre-wipe state, so let the surface's hidden-state effects drive the new load.
if (stateIdentityRef.current === null) {
return
}
diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts
new file mode 100644
index 00000000000..6ab43487475
--- /dev/null
+++ b/mobile/src/session/use-quick-commands.test.ts
@@ -0,0 +1,391 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import type { TerminalQuickCommand } from '../../../src/shared/types'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse } from '../transport/types'
+import { useQuickCommands } from './use-quick-commands'
+
+const FIRST: TerminalQuickCommand = {
+ id: 'first',
+ label: 'First',
+ action: 'terminal-command',
+ command: 'echo first',
+ appendEnter: true,
+ scope: { type: 'global' }
+}
+const SECOND: TerminalQuickCommand = {
+ id: 'second',
+ label: 'Second',
+ action: 'terminal-command',
+ command: 'echo second',
+ appendEnter: true,
+ scope: { type: 'global' }
+}
+
+function success(commands: TerminalQuickCommand[]): RpcResponse {
+ return {
+ ok: true,
+ result: { terminalQuickCommands: commands }
+ } as RpcResponse
+}
+
+function failure(message: string): RpcResponse {
+ return {
+ ok: false,
+ error: { code: 'internal_error', message }
+ } as RpcResponse
+}
+
+function deferred() {
+ let resolve: (value: T) => void = () => {}
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+describe('useQuickCommands', () => {
+ let renderer: ReactTestRenderer | null = null
+ let state: ReturnType | null = null
+ let consoleSpy: MockInstance
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ const original = console.error
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...args)
+ })
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ state = null
+ consoleSpy.mockRestore()
+ })
+
+ async function mount(client: RpcClient, enabled = true): Promise {
+ function Harness(): null {
+ state = useQuickCommands({ client, enabled })
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ await Promise.resolve()
+ })
+ }
+
+ it('does not fetch settings while the sheet is closed', async () => {
+ const client = { sendRequest: vi.fn() } as unknown as RpcClient
+
+ await mount(client, false)
+
+ expect(client.sendRequest).not.toHaveBeenCalled()
+ expect(state?.ready).toBe(false)
+ })
+
+ it('keeps mutations disabled when the remote list could not be loaded', async () => {
+ const client = {
+ sendRequest: vi.fn().mockResolvedValue(failure('load failed'))
+ } as unknown as RpcClient
+ await mount(client)
+
+ await act(async () => {
+ const persisted = await state!.persist({ type: 'delete', id: FIRST.id })
+ expect(persisted).toBe(false)
+ })
+
+ expect(state?.ready).toBe(false)
+ expect(state?.error).toBe('load failed')
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps mutations disabled when a successful load has a malformed list', async () => {
+ const client = {
+ sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} } as RpcResponse)
+ } as unknown as RpcClient
+ await mount(client)
+
+ await act(async () => {
+ const persisted = await state!.persist({ type: 'delete', id: FIRST.id })
+ expect(persisted).toBe(false)
+ })
+
+ expect(state?.ready).toBe(false)
+ expect(state?.error).toBe('Failed to load quick commands')
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps a later queued mutation after an earlier mutation fails', async () => {
+ const firstUpdate = deferred()
+ const secondUpdate = deferred()
+ const updateParams: unknown[] = []
+ const client = {
+ sendRequest: vi.fn((method: string, params?: unknown) => {
+ if (method === 'settings.getTerminalQuickCommands') {
+ return Promise.resolve(success([FIRST, SECOND]))
+ }
+ updateParams.push(params)
+ return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise
+ })
+ } as unknown as RpcClient
+ await mount(client)
+ expect(state?.commands).toEqual([FIRST, SECOND])
+
+ let firstPersist: Promise = Promise.resolve(false)
+ let secondPersist: Promise = Promise.resolve(false)
+ await act(async () => {
+ firstPersist = state!.persist({ type: 'delete', id: FIRST.id })
+ secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
+ await Promise.resolve()
+ })
+
+ expect(state?.commands).toEqual([])
+ expect(updateParams).toHaveLength(1)
+ expect(updateParams[0]).toEqual({ mutation: { type: 'delete', id: FIRST.id } })
+
+ await act(async () => {
+ firstUpdate.resolve(failure('first failed'))
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+ expect(updateParams).toEqual([
+ { mutation: { type: 'delete', id: FIRST.id } },
+ { mutation: { type: 'delete', id: SECOND.id } }
+ ])
+ expect(state?.commands).toEqual([FIRST])
+
+ await act(async () => {
+ secondUpdate.resolve(success([FIRST]))
+ await Promise.all([firstPersist, secondPersist])
+ })
+ expect(await firstPersist).toBe(false)
+ expect(await secondPersist).toBe(true)
+ expect(state?.commands).toEqual([FIRST])
+ expect(state?.error).toBeNull()
+ })
+
+ it('adopts the latest server-normalized list between queued mutations', async () => {
+ const normalizedFirst = { ...FIRST, label: 'Server normalized' }
+ const firstUpdate = deferred()
+ const secondUpdate = deferred()
+ const updateParams: unknown[] = []
+ const client = {
+ sendRequest: vi.fn((method: string, params?: unknown) => {
+ if (method === 'settings.getTerminalQuickCommands') {
+ return Promise.resolve(success([FIRST, SECOND]))
+ }
+ updateParams.push(params)
+ return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise
+ })
+ } as unknown as RpcClient
+ await mount(client)
+
+ let firstPersist: Promise = Promise.resolve(false)
+ let secondPersist: Promise = Promise.resolve(false)
+ await act(async () => {
+ firstPersist = state!.persist({
+ type: 'upsert',
+ command: { ...FIRST, label: 'Local label' }
+ })
+ secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
+ await Promise.resolve()
+ })
+
+ expect(updateParams[0]).toEqual({
+ mutation: { type: 'upsert', command: { ...FIRST, label: 'Local label' } }
+ })
+ await act(async () => {
+ firstUpdate.resolve(success([normalizedFirst, SECOND]))
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+ expect(updateParams[1]).toEqual({ mutation: { type: 'delete', id: SECOND.id } })
+
+ await act(async () => {
+ secondUpdate.resolve(success([normalizedFirst]))
+ await Promise.all([firstPersist, secondPersist])
+ })
+ expect(state?.commands).toEqual([normalizedFirst])
+ })
+
+ it('preserves unrelated commands added by another client while the sheet is open', async () => {
+ const edited = { ...FIRST, label: 'Edited on mobile' }
+ const client = {
+ sendRequest: vi
+ .fn()
+ .mockResolvedValueOnce(success([FIRST]))
+ // The host applies the targeted upsert to a list that desktop changed.
+ .mockResolvedValueOnce(success([edited, SECOND]))
+ } as unknown as RpcClient
+ await mount(client)
+
+ await act(async () => {
+ await state!.persist({ type: 'upsert', command: edited })
+ })
+
+ expect(client.sendRequest).toHaveBeenLastCalledWith('settings.updateTerminalQuickCommands', {
+ mutation: { type: 'upsert', command: edited }
+ })
+ expect(state?.commands).toEqual([edited, SECOND])
+ })
+
+ it('isolates an old client mutation from a replacement client', async () => {
+ const oldUpdate = deferred()
+ const oldClient = {
+ sendRequest: vi
+ .fn()
+ .mockResolvedValueOnce(success([FIRST]))
+ .mockReturnValueOnce(oldUpdate.promise)
+ } as unknown as RpcClient
+ const newClient = {
+ sendRequest: vi.fn().mockResolvedValue(success([SECOND]))
+ } as unknown as RpcClient
+
+ function Harness({ client }: { client: RpcClient }): null {
+ state = useQuickCommands({ client, enabled: true })
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness, { client: oldClient }))
+ await Promise.resolve()
+ })
+ let persisted: Promise = Promise.resolve(false)
+ await act(async () => {
+ persisted = state!.persist({ type: 'delete', id: FIRST.id })
+ await Promise.resolve()
+ renderer!.update(createElement(Harness, { client: newClient }))
+ await Promise.resolve()
+ })
+
+ expect(newClient.sendRequest).toHaveBeenCalledWith('settings.getTerminalQuickCommands')
+ expect(state?.commands).toEqual([SECOND])
+
+ await act(async () => {
+ oldUpdate.resolve(success([]))
+ await persisted
+ })
+ expect(state?.commands).toEqual([SECOND])
+ })
+
+ it('rolls back the latest optimistic mutation when persistence fails', async () => {
+ const client = {
+ sendRequest: vi
+ .fn()
+ .mockResolvedValueOnce(success([FIRST]))
+ .mockResolvedValueOnce(failure('save failed'))
+ } as unknown as RpcClient
+ await mount(client)
+
+ await act(async () => {
+ await state!.persist({ type: 'delete', id: FIRST.id })
+ })
+
+ expect(state?.commands).toEqual([FIRST])
+ expect(state?.error).toBe('save failed')
+ })
+
+ it('rolls back when a successful update has a malformed authoritative list', async () => {
+ const client = {
+ sendRequest: vi
+ .fn()
+ .mockResolvedValueOnce(success([FIRST]))
+ .mockResolvedValueOnce({ ok: true, result: {} } as RpcResponse)
+ } as unknown as RpcClient
+ await mount(client)
+
+ let persisted = true
+ await act(async () => {
+ persisted = await state!.persist({ type: 'delete', id: FIRST.id })
+ })
+
+ expect(persisted).toBe(false)
+ expect(state?.commands).toEqual([FIRST])
+ expect(state?.error).toBe('Failed to save quick command')
+ })
+
+ it('waits for an in-flight save before reloading after reopen', async () => {
+ const update = deferred()
+ let loadCount = 0
+ const client = {
+ sendRequest: vi.fn((method: string) => {
+ if (method === 'settings.updateTerminalQuickCommands') {
+ return update.promise
+ }
+ loadCount += 1
+ return Promise.resolve(success(loadCount === 1 ? [FIRST] : []))
+ })
+ } as unknown as RpcClient
+
+ function Harness({ enabled }: { enabled: boolean }): null {
+ state = useQuickCommands({ client, enabled })
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness, { enabled: true }))
+ await Promise.resolve()
+ })
+
+ let persisted: Promise = Promise.resolve(false)
+ await act(async () => {
+ persisted = state!.persist({ type: 'delete', id: FIRST.id })
+ await Promise.resolve()
+ })
+ await act(async () => {
+ renderer!.update(createElement(Harness, { enabled: false }))
+ await Promise.resolve()
+ })
+ await act(async () => {
+ renderer!.update(createElement(Harness, { enabled: true }))
+ await Promise.resolve()
+ })
+ expect(loadCount).toBe(1)
+
+ await act(async () => {
+ update.resolve(success([]))
+ await persisted
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(loadCount).toBe(2)
+ expect(state?.commands).toEqual([])
+ expect(state?.ready).toBe(true)
+ })
+
+ it('rolls back to the confirmed list when consecutive queued mutations fail', async () => {
+ const firstUpdate = deferred()
+ const secondUpdate = deferred()
+ const client = {
+ sendRequest: vi
+ .fn()
+ .mockResolvedValueOnce(success([FIRST, SECOND]))
+ .mockReturnValueOnce(firstUpdate.promise)
+ .mockReturnValueOnce(secondUpdate.promise)
+ } as unknown as RpcClient
+ await mount(client)
+
+ let firstPersist: Promise = Promise.resolve(false)
+ let secondPersist: Promise = Promise.resolve(false)
+ await act(async () => {
+ firstPersist = state!.persist({ type: 'delete', id: FIRST.id })
+ secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
+ await Promise.resolve()
+ })
+
+ await act(async () => {
+ firstUpdate.resolve(failure('first failed'))
+ await Promise.resolve()
+ await Promise.resolve()
+ secondUpdate.resolve(failure('second failed'))
+ await Promise.all([firstPersist, secondPersist])
+ })
+
+ expect(state?.commands).toEqual([FIRST, SECOND])
+ expect(state?.error).toBe('second failed')
+ })
+})
diff --git a/mobile/src/session/use-quick-commands.ts b/mobile/src/session/use-quick-commands.ts
new file mode 100644
index 00000000000..5ce5a994cd0
--- /dev/null
+++ b/mobile/src/session/use-quick-commands.ts
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+import type { TerminalQuickCommand } from '../../../src/shared/types'
+import {
+ applyTerminalQuickCommandMutation,
+ parseNormalizedTerminalQuickCommands,
+ type TerminalQuickCommandMutation
+} from '../terminal/quick-commands'
+
+type Args = {
+ client: RpcClient | null
+ // Fetch only while the sheet is open — quick commands are settings data we
+ // don't need to keep hydrated for every session screen.
+ enabled: boolean
+}
+
+type QuickCommandsState = {
+ commands: TerminalQuickCommand[]
+ loading: boolean
+ ready: boolean
+ error: string | null
+ // Optimistically apply against the latest local list, then serialize writes.
+ // The server re-normalizes and returns the canonical list, which we adopt.
+ persist: (mutation: TerminalQuickCommandMutation) => Promise
+}
+
+type PendingMutation = {
+ id: number
+ mutation: TerminalQuickCommandMutation
+}
+
+type MutationContext = {
+ client: RpcClient
+ confirmed: TerminalQuickCommand[]
+ pending: PendingMutation[]
+ queue: Promise
+ nextMutationId: number
+}
+
+function readQuickCommands(result: unknown): TerminalQuickCommand[] | null {
+ const list = (result as { terminalQuickCommands?: unknown } | null)?.terminalQuickCommands
+ return parseNormalizedTerminalQuickCommands(list)
+}
+
+export function useQuickCommands({ client, enabled }: Args): QuickCommandsState {
+ const [commands, setCommands] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [ready, setReady] = useState(false)
+ const [error, setError] = useState(null)
+ const commandsRef = useRef([])
+ const operationIdRef = useRef(0)
+ const mutationContextRef = useRef(null)
+
+ useEffect(() => {
+ if (!enabled || !client) {
+ setReady(false)
+ return
+ }
+ let mutationContext = mutationContextRef.current
+ if (mutationContext?.client !== client) {
+ // A request for an old host must not delay or update mutations on a new one.
+ mutationContext = {
+ client,
+ confirmed: [],
+ pending: [],
+ queue: Promise.resolve(),
+ nextMutationId: 0
+ }
+ mutationContextRef.current = mutationContext
+ commandsRef.current = []
+ setCommands([])
+ }
+ let stale = false
+ const operationId = operationIdRef.current + 1
+ operationIdRef.current = operationId
+ setLoading(true)
+ setReady(false)
+ setError(null)
+
+ void (async () => {
+ try {
+ // A close/reopen can overlap an in-flight save. Read only after that
+ // save settles so an older snapshot cannot replace its canonical result.
+ await mutationContext.queue
+ if (
+ stale ||
+ operationId !== operationIdRef.current ||
+ mutationContextRef.current !== mutationContext
+ ) {
+ return
+ }
+ const response = await client.sendRequest('settings.getTerminalQuickCommands')
+ if (
+ stale ||
+ operationId !== operationIdRef.current ||
+ mutationContextRef.current !== mutationContext
+ ) {
+ return
+ }
+ if (!response.ok) {
+ setError((response as RpcFailure).error.message || 'Failed to load quick commands')
+ return
+ }
+ const next = readQuickCommands((response as RpcSuccess).result)
+ if (!next) {
+ setError('Failed to load quick commands')
+ return
+ }
+ mutationContext.confirmed = next
+ commandsRef.current = next
+ setCommands(next)
+ setReady(true)
+ } catch (err) {
+ if (
+ !stale &&
+ operationId === operationIdRef.current &&
+ mutationContextRef.current === mutationContext
+ ) {
+ setError(err instanceof Error ? err.message : 'Failed to load quick commands')
+ }
+ } finally {
+ if (
+ !stale &&
+ operationId === operationIdRef.current &&
+ mutationContextRef.current === mutationContext
+ ) {
+ setLoading(false)
+ }
+ }
+ })()
+
+ return () => {
+ stale = true
+ }
+ }, [client, enabled])
+
+ const persist = useCallback(
+ async (commandMutation: TerminalQuickCommandMutation) => {
+ // Why: the loaded list is the optimistic/rollback baseline; mutating
+ // before it arrives would make failure recovery show invented state.
+ const mutationContext = mutationContextRef.current
+ if (!client || loading || !ready || mutationContext?.client !== client) {
+ return false
+ }
+ const mutation: PendingMutation = {
+ id: mutationContext.nextMutationId + 1,
+ mutation: commandMutation
+ }
+ mutationContext.nextMutationId = mutation.id
+ mutationContext.pending.push(mutation)
+ const optimistic = applyTerminalQuickCommandMutation(commandsRef.current, commandMutation)
+ commandsRef.current = optimistic
+ setCommands(optimistic)
+ setError(null)
+
+ const send = async (): Promise => {
+ let succeeded = false
+ let failureMessage: string | null = null
+ try {
+ const response = await client.sendRequest('settings.updateTerminalQuickCommands', {
+ mutation: commandMutation
+ })
+ if (!response.ok) {
+ throw new Error(
+ (response as RpcFailure).error.message || 'Failed to save quick command'
+ )
+ }
+ const confirmed = readQuickCommands((response as RpcSuccess).result)
+ if (!confirmed) {
+ // Why: treating an invalid success payload as [] would let the next
+ // full-list mutation erase commands that still exist on the host.
+ throw new Error('Failed to save quick command')
+ }
+ mutationContext.confirmed = confirmed
+ succeeded = true
+ return true
+ } catch (err) {
+ failureMessage = err instanceof Error ? err.message : 'Failed to save quick command'
+ return false
+ } finally {
+ mutationContext.pending = mutationContext.pending.filter(
+ (pending) => pending.id !== mutation.id
+ )
+ if (mutationContextRef.current === mutationContext) {
+ const next = mutationContext.pending.reduce(
+ (current, pending) => applyTerminalQuickCommandMutation(current, pending.mutation),
+ mutationContext.confirmed
+ )
+ commandsRef.current = next
+ setCommands(next)
+ const hasNewerMutation = mutationContext.pending.some(
+ (pending) => pending.id > mutation.id
+ )
+ if (!hasNewerMutation) {
+ setError(succeeded ? null : failureMessage)
+ }
+ }
+ }
+ }
+ const request = mutationContext.queue.then(send, send)
+ mutationContext.queue = request.then(
+ () => undefined,
+ () => undefined
+ )
+ return await request
+ },
+ [client, loading, ready]
+ )
+
+ return { commands, loading, ready, error, persist }
+}
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index 25656aae2bd..9c3ebb27062 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -46,16 +46,13 @@ export function MobileSourceControlPanel({
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState(initialTab)
- // Track first visit so Changes/History keep scroll state across segment switches
- // without paying the mount cost until the user actually opens them. PR unmounts
- // when inactive (WebViews / comment tree) but its controller state stays for the chip.
+ // Track first visit so Changes/History stay mounted (keep scroll) after first open; PR still unmounts when inactive.
const [visitedTabs, setVisitedTabs] = useState>(
() => new Set([initialTab])
)
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
- // Deep-link / push with a different `tab` param should adopt the new segment
- // (expo-router can reuse the screen instance when only query params change).
+ // expo-router reuses the screen instance when only query params change, so adopt the new tab.
useEffect(() => {
setActiveTab(initialTab)
setVisitedTabs((prev) => {
@@ -119,13 +116,7 @@ export function MobileSourceControlPanel({
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const ready = screenState.kind === 'ready'
- // One PR controller feeds both the branch-card chip and the Pull Request
- // segment, so the chip's rollup can never disagree with the checks list it
- // links to. Branch + head come from the already-loaded git.status — no second
- // status read. The chip loads independently, so it never blocks the file list.
- // Keep last-known identity across a transient status unload (disconnect /
- // failed refresh) so the controller does not wipe ready → hidden → cold start.
- // Head SHA matches the review path: status.head ?? branchCompare headOid.
+ // Keep last-known branch/head across a transient status unload so the PR controller isn't wiped ready → hidden → cold start.
const lastPrBranchRef = useRef(null)
const lastPrHeadRef = useRef(null)
useEffect(() => {
@@ -134,8 +125,7 @@ export function MobileSourceControlPanel({
}, [worktreeId])
const statusBranch = status?.branch ?? null
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
- // Write last-known identity in an effect, not the render body: a discarded
- // concurrent render must not leave the fallback holding a never-committed value.
+ // Write last-known identity in an effect, not render: a discarded concurrent render must not leave the fallback stale.
useEffect(() => {
if (statusBranch) {
lastPrBranchRef.current = statusBranch
@@ -163,8 +153,7 @@ export function MobileSourceControlPanel({
const ensurePrDetailsRef = useRef(ensurePrDetails)
ensurePrDetailsRef.current = ensurePrDetails
- // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
- // the Pull Request segment is open — opening SC to stage must not pull details.
+ // Chip bootstrap: phase-1 only (PR + checks); full payload waits for the PR segment so opening SC to stage doesn't pull details.
useEffect(() => {
if (activeTab === 'pr') {
return
@@ -174,22 +163,14 @@ export function MobileSourceControlPanel({
}
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
- // Number of the ready PR whose phase-2 details are still missing (null when
- // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
- // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
- // comments on a forever-spinner (a stale ensure bails on the number mismatch).
- // Placeholder details (failed phase 2) also count as missing so reopening the
- // PR tab retries instead of leaving empty Description/Comments forever.
+ // Keyed by PR number, not a boolean, so a same-branch PR swap re-arms phase 2 for the new PR.
const prDetailsMissingFor =
prController.prSidebarState.kind === 'ready' &&
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
? prController.prSidebarState.data.pr.number
: null
- // PR segment: full load or phase-2 fill-in. Body only mounts while active.
- // Guarded (and keyed) on prBranch so a branch that arrives while the segment is
- // already open — e.g. mounted on a detached HEAD, then checkout — still loads;
- // kind stays 'hidden' through that transition, so kind alone can't re-fire this.
+ // PR segment full load / phase-2 fill-in; key on prBranch so a branch arriving while open still loads (kind stays 'hidden').
useEffect(() => {
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
return
@@ -204,9 +185,7 @@ export function MobileSourceControlPanel({
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
const prChip = useMemo(() => {
- // No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared
- // state stays 'hidden' — which the chip would render as a forever spinner.
- // Hide the chip instead; the Pull Request segment shows "branch unavailable".
+ // No branch (detached HEAD / mid-rebase) never loads a PR, so state stays 'hidden'; hide the chip or it spins forever.
if (!isHostedRepo || !prBranch) {
return null
}
@@ -217,9 +196,7 @@ export function MobileSourceControlPanel({
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prBranch, prController.prSidebarState])
- // Design: refresh the active segment's body work, plus git.status for the shared
- // branch card (counts/sync stay honest even while on History). Preserve ready
- // status on a failed refresh so PR chip identity is not wiped to hidden.
+ // Refresh the active segment plus git.status (branch card stays honest on History); preserve ready on failure so the PR chip isn't wiped.
const onRefresh = useCallback(() => {
void loadStatus({ preserveReadyOnFailure: true })
if (activeTab === 'history') {
@@ -237,11 +214,9 @@ export function MobileSourceControlPanel({
void refetchPr({ includeDetails: false })
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
- // Embedded mode docks beside the terminal: close the dock instead of popping
- // a route, and skip the full-screen safe-area chrome (the dock column owns it).
+ // Embedded mode docks beside the terminal: close the dock instead of popping a route; skip safe-area chrome (the dock column owns it).
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
- // Chromeless PR body has no panel header — surface open-on-web on the hub chrome
- // while the Pull Request segment is active (same affordance as the old /pr route).
+ // Chromeless PR body has no header, so surface open-on-web on the hub chrome while the PR segment is active.
const prWebUrl =
activeTab === 'pr' &&
prController.prSidebarState.kind === 'ready' &&
@@ -277,10 +252,7 @@ export function MobileSourceControlPanel({
{
- // Why: retrying the request is useless while the transport's
- // reconnect loop is parked at its give-up cap — revive the
- // connection instead (issue #5049). loadStatus re-runs via
- // its connState effect once the new client connects.
+ // Why: a parked reconnect loop makes retry useless — revive the connection instead (issue #5049); loadStatus re-runs on reconnect.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
@@ -294,13 +266,10 @@ export function MobileSourceControlPanel({
) : null
- // History only needs the RPC client — do not block it behind git.status.
- // Changes/PR need status (branch, file list, head SHA), so they stay gated.
+ // History only needs the RPC client, so it isn't gated on git.status; Changes/PR need status (branch, files, head).
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
const showHistory = activeTab === 'history' || visitedTabs.has('history')
- // Why: only mount the PR body while its segment is active. Keep-mounting retained
- // Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
- // Controller + chip state still live for instant re-open without a full cold start.
+ // Only mount PR body while active: keep-mounting retained Mermaid WebViews and re-rendered the comment tree per keystroke.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
// Git status always reports a conflictOperation enum; 'unknown' means none.
@@ -319,10 +288,7 @@ export function MobileSourceControlPanel({
- {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
- tab they duplicate the ready PR body (#, state, checks rollup, branch
- trajectory), so hide the whole card there and let the PR panel own it —
- unless a merge/rebase conflict is active, which only this card can abort. */}
+ {/* On the PR tab the branch card duplicates the ready PR body, so hide it there — unless a conflict is active, which only this card can abort. */}
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
index 13e43d45b43..b636ea12d3e 100644
--- a/mobile/src/storage/preferences.test.ts
+++ b/mobile/src/storage/preferences.test.ts
@@ -24,6 +24,7 @@ import {
import {
loadDefaultSessionView,
loadSessionViewOverrides,
+ readDefaultSessionViewPreference,
readSessionViewOverridesPreference,
saveDefaultSessionView,
updateSessionViewOverride
@@ -62,6 +63,39 @@ describe('session view preference', () => {
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
})
+ it('reports an absent default as an undecided (null) preference', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: true,
+ hasStoredValue: false
+ })
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('chat')
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: 'chat',
+ loaded: true,
+ hasStoredValue: true
+ })
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus')
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: true,
+ hasStoredValue: true
+ })
+ })
+
+ it('marks an unreadable default as not loaded so the opt-in gate can bail', async () => {
+ vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
+ await expect(readDefaultSessionViewPreference()).resolves.toEqual({
+ value: null,
+ loaded: false,
+ hasStoredValue: false
+ })
+ await expect(loadDefaultSessionView()).resolves.toBe('terminal')
+ })
+
it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' })
diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts
index 750167e3d53..cd9597d6f77 100644
--- a/mobile/src/storage/session-view-preferences.ts
+++ b/mobile/src/storage/session-view-preferences.ts
@@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise): void {
}
}
-/** Global (per-device) default for how supported agent sessions open. */
-export async function loadDefaultSessionView(): Promise {
+export type DefaultSessionViewPreference = {
+ readonly value: MobileSessionView | null
+ readonly loaded: boolean
+ readonly hasStoredValue: boolean
+}
+
+/** Reads the raw per-device default and whether its storage key exists. */
+export async function readDefaultSessionViewPreference(): Promise {
await defaultViewWriteBarrier
try {
const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY)
- return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW
+ return {
+ value: raw === 'chat' || raw === 'terminal' ? raw : null,
+ loaded: true,
+ hasStoredValue: raw !== null
+ }
} catch {
- return DEFAULT_SESSION_VIEW
+ return { value: null, loaded: false, hasStoredValue: false }
}
}
+/** Global (per-device) default for how supported agent sessions open. */
+export async function loadDefaultSessionView(): Promise {
+ return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW
+}
+
export function saveDefaultSessionView(view: MobileSessionView): Promise {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.
diff --git a/mobile/src/terminal/quick-commands.test.ts b/mobile/src/terminal/quick-commands.test.ts
new file mode 100644
index 00000000000..964fbc9b4d2
--- /dev/null
+++ b/mobile/src/terminal/quick-commands.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import type { TerminalQuickCommand } from '../../../src/shared/types'
+import {
+ buildMobileQuickCommandLaunch,
+ getQuickCommandDisplayPreview,
+ getQuickCommandPreview,
+ supportsMobileQuickCommands
+} from './quick-commands'
+
+function command(overrides: Partial = {}): TerminalQuickCommand {
+ return {
+ id: 'command',
+ label: 'Command',
+ action: 'terminal-command',
+ command: 'pnpm test',
+ appendEnter: true,
+ scope: { type: 'global' },
+ ...overrides
+ } as TerminalQuickCommand
+}
+
+describe('mobile quick-command launch', () => {
+ it('only exposes quick commands when the host advertises the complete contract', () => {
+ expect(supportsMobileQuickCommands(undefined)).toBe(false)
+ expect(supportsMobileQuickCommands([])).toBe(false)
+ expect(supportsMobileQuickCommands(['terminal.binary-stream.v1'])).toBe(false)
+ expect(supportsMobileQuickCommands(['terminal.quick-commands.v1'])).toBe(true)
+ })
+
+ it('joins multiline runnable commands with "; " to match desktop', () => {
+ // Parity with desktop flattenTerminalQuickCommand: the same saved command
+ // must run identically on desktop and mobile.
+ expect(
+ buildMobileQuickCommandLaunch(command({ command: 'cd app\nnpm install\nnpm test' }))
+ ).toEqual({
+ options: {
+ startupCommand: 'cd app; npm install; npm test',
+ startupCommandDelivery: 'shell-ready'
+ }
+ })
+ })
+
+ it('forces shell-ready delivery for commands that resemble bare agent launches', () => {
+ expect(buildMobileQuickCommandLaunch(command({ command: 'codex exec --full-auto' }))).toEqual({
+ options: {
+ startupCommand: 'codex exec --full-auto',
+ startupCommandDelivery: 'shell-ready'
+ }
+ })
+ })
+
+ it('keeps append-enter-off commands as unsubmitted terminal input', () => {
+ const multiline = 'printf "first\\nsecond"\n# leave this unsubmitted'
+ expect(
+ buildMobileQuickCommandLaunch(command({ command: multiline, appendEnter: false }))
+ ).toEqual({
+ options: { initialPrompt: multiline, enter: false, successToast: 'Command inserted' }
+ })
+ })
+
+ it('injects supported agent prompts into the host-built launch command', () => {
+ expect(
+ buildMobileQuickCommandLaunch(
+ command({
+ action: 'agent-prompt',
+ agent: 'codex',
+ prompt: 'Review this diff'
+ })
+ )
+ ).toEqual({ agent: 'codex', options: { agentPrompt: 'Review this diff' } })
+ })
+
+ it('bounds native row text without truncating searchable or executable content', () => {
+ const longPrompt = `Review ${'x'.repeat(5993)}`
+ const agentCommand = command({
+ action: 'agent-prompt',
+ agent: 'codex',
+ prompt: longPrompt
+ })
+
+ expect(getQuickCommandDisplayPreview(agentCommand)).toHaveLength(240)
+ expect(getQuickCommandDisplayPreview(agentCommand)).toMatch(/^Codex: Review .*…$/)
+ expect(getQuickCommandPreview(agentCommand)).toBe(`Codex: ${longPrompt}`)
+ expect(buildMobileQuickCommandLaunch(agentCommand)).toEqual({
+ agent: 'codex',
+ options: { agentPrompt: longPrompt }
+ })
+ })
+})
diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts
new file mode 100644
index 00000000000..40f92a55439
--- /dev/null
+++ b/mobile/src/terminal/quick-commands.ts
@@ -0,0 +1,105 @@
+import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
+import {
+ applyTerminalQuickCommandMutation,
+ flattenTerminalQuickCommand,
+ isTerminalAgentQuickCommand,
+ MAX_QUICK_COMMANDS,
+ MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
+ MAX_QUICK_COMMAND_LABEL_LENGTH,
+ MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
+ parseNormalizedTerminalQuickCommands,
+ supportsTerminalAgentQuickCommand,
+ terminalQuickCommandMatchesRepo,
+ type TerminalQuickCommandMutation
+} from '../../../src/shared/terminal-quick-commands'
+import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
+import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents'
+
+// Reuse the canonical desktop quick-command logic (pure, no heavy deps) so
+// mobile behaves identically to desktop. Only genuinely mobile-specific pieces
+// — agent-branded labels, native row truncation, and the launch plan — live here.
+export {
+ isTerminalAgentQuickCommand as isAgentQuickCommand,
+ terminalQuickCommandMatchesRepo as quickCommandMatchesRepo,
+ supportsTerminalAgentQuickCommand,
+ MAX_QUICK_COMMANDS,
+ MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
+ MAX_QUICK_COMMAND_LABEL_LENGTH,
+ MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
+ parseNormalizedTerminalQuickCommands,
+ applyTerminalQuickCommandMutation,
+ type TerminalQuickCommandMutation
+}
+
+const MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH = 240
+
+export function supportsMobileQuickCommands(capabilities: readonly string[] | undefined): boolean {
+ return capabilities?.includes(TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY) === true
+}
+
+export type MobileQuickCommandLaunch = {
+ agent?: TuiAgent
+ options: {
+ agentPrompt?: string
+ startupCommand?: string
+ startupCommandDelivery?: 'shell-ready'
+ initialPrompt?: string
+ enter?: boolean
+ successToast?: string
+ }
+}
+
+export function buildMobileQuickCommandLaunch(
+ command: TerminalQuickCommand
+): MobileQuickCommandLaunch | null {
+ if (isTerminalAgentQuickCommand(command)) {
+ if (!command.prompt.trim() || !supportsTerminalAgentQuickCommand(command.agent)) {
+ return null
+ }
+ return { agent: command.agent, options: { agentPrompt: command.prompt } }
+ }
+ if (!command.command.trim()) {
+ return null
+ }
+ return command.appendEnter === false
+ ? {
+ options: {
+ initialPrompt: command.command,
+ enter: false,
+ successToast: `${command.label.trim() || 'Quick command'} inserted`
+ }
+ }
+ : {
+ // Why: raw commands that resemble bare agent launches can otherwise
+ // select the fast path and race slow native, WSL, or SSH shell startup.
+ // flattenTerminalQuickCommand joins multiline bodies exactly as desktop
+ // does so the same saved command runs identically on both.
+ options: {
+ startupCommand: flattenTerminalQuickCommand(command).command,
+ startupCommandDelivery: 'shell-ready'
+ }
+ }
+}
+
+export function getQuickCommandAgentLabel(agent: TuiAgent): string {
+ return MOBILE_TUI_AGENT_LABELS[agent] ?? agent
+}
+
+// The subtitle desktop shows under each quick command: agent prompts read
+// "Codex: ", terminal commands show the raw command text.
+export function getQuickCommandPreview(command: TerminalQuickCommand): string {
+ if (isTerminalAgentQuickCommand(command)) {
+ return `${getQuickCommandAgentLabel(command.agent)}: ${command.prompt}`
+ }
+ return command.command
+}
+
+export function getQuickCommandDisplayPreview(command: TerminalQuickCommand): string {
+ const preview = getQuickCommandPreview(command)
+ if (preview.length <= MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH) {
+ return preview
+ }
+ // Why: one-line rows should not send up to 6 KB each through native text
+ // layout; full command bodies remain available to search, edit, and launch.
+ return `${preview.slice(0, MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH - 1)}…`
+}
diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts
index e54c7c47737..6194fabc675 100644
--- a/mobile/src/terminal/terminal-viewport-refit.ts
+++ b/mobile/src/terminal/terminal-viewport-refit.ts
@@ -28,14 +28,9 @@ type TerminalViewportRefitOptions = {
initializedHandlesRef: RefObject>
connState: ConnectionState
tabStripVisible: boolean
- // Why: terminal text size (font scale) — changing it changes the cell size, so
- // the PTY must be re-fitted to a new column count and reflowed.
+ // Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count.
textScale: number
- // Why: the terminal's measured frame width changes when a side panel docks/undocks
- // or EITHER sidebar is drag-resized (the left worktree sidebar shrinks the detail
- // pane; the right dock takes a slice of the row) — all without any window-dim or
- // tab-strip change. Carries that measured width so those resizes re-fit the PTY;
- // the 150ms debounce coalesces the stream of drag widths into one settle-time refit.
+ // Why: measured frame width; panel dock/undock or sidebar resize changes it with no window/tab change, so it re-fits the PTY.
terminalFrameWidth: number
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
@@ -46,12 +41,7 @@ type TerminalViewportRefitNotifications = {
notifyKeyboardVisibility: (visible: boolean) => void
}
-// Why: re-measure the phone viewport when layout-affecting state changes
-// outside the subscribe path — the tab strip toggling visibility, and the
-// window itself resizing (fold/unfold on foldables, orientation rotation,
-// split-screen). Without the resize trigger, a PTY fitted on the folded
-// cover screen stays at cover-screen cols after unfolding and the terminal
-// renders in only part of the display (#4579's "cut in half" symptom).
+// Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579).
export function useTerminalViewportRefit(
options: TerminalViewportRefitOptions
): TerminalViewportRefitNotifications {
@@ -82,9 +72,7 @@ export function useTerminalViewportRefit(
keyboardVisible: false,
pending: false
})
- // Why: marks the currently-armed timer as a height refit so its callback can
- // re-check the keyboard at fire time. Non-height refits (width/rotation and
- // the forced reconnect/foreground re-asserts) stay unguarded so they always run.
+ // Why: marks the armed timer as a height refit so its callback re-checks the keyboard; other refits always run unguarded.
const heightOriginatedRefitRef = useRef(false)
const scheduleViewportRefit = useCallback(
(options?: { heightOriginated?: boolean }) => {
@@ -94,9 +82,7 @@ export function useTerminalViewportRefit(
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
- // Why: a height refit deferred at keyboard-close can fire after the keyboard
- // reopened within the 150ms debounce; re-check and re-defer so we never
- // reflow the PTY mid-keystroke. Scoped via the height-originated flag.
+ // Why: a height refit can fire after the keyboard reopened within the debounce; re-check so we never reflow the PTY mid-keystroke.
if (heightOriginatedRefitRef.current) {
heightOriginatedRefitRef.current = false
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
@@ -143,11 +129,7 @@ export function useTerminalViewportRefit(
}
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.
+ // Why: prefer in-place updateViewport over resubscribe to keep the mobile subscriber record alive. See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
@@ -165,11 +147,7 @@ export function useTerminalViewportRefit(
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
- // Why: updateViewport reflows the server PTY and re-streams only
- // the visible screen, so the WebView's local xterm scrollback
- // stays wrapped at the old width. Reflow it locally only when
- // the server actually applied phone-fit; desktop mode records
- // the viewport but leaves the PTY at desktop dims.
+ // Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally.
ref.reflow(dims.cols, dims.rows)
}
return
@@ -205,14 +183,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [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) re-runs against the new layout.
- // Also schedule an explicit refit to cover the case where no new
- // subscribe is happening.
+ // Why: the tab strip toggles at the 1↔2 terminal boundary (~40px area change), so the cached viewport goes stale.
const prevTabStripVisibleRef = useRef(tabStripVisible)
useEffect(() => {
if (prevTabStripVisibleRef.current === tabStripVisible) {
@@ -223,10 +194,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [tabStripVisible, viewportMeasuredRef, scheduleViewportRefit])
- // Why: fold/unfold and rotation change the window dimensions without any
- // subscribe or tab-strip transition. The PTY must be re-fitted to the new
- // viewport or the terminal keeps the old grid (fit scale is capped at 1,
- // so a grown window leaves the surface pinned to a fraction of the screen).
+ // Why: fold/unfold and rotation change window dims with no subscribe/tab change; refit or the grid stays stale (fit capped at 1).
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
const prevWindowDimsRef = useRef({ width: windowWidth, height: windowHeight })
useEffect(() => {
@@ -235,8 +203,7 @@ export function useTerminalViewportRefit(
return
}
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
- // Why: adjustResize can change only window height while the IME is open;
- // the frame-height notifier schedules one correction after it closes.
+ // Why: adjustResize can change only window height while the IME is open; the frame-height notifier corrects once it closes.
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
return
}
@@ -244,10 +211,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
- // Why: the text size changed, so the WebView is re-rendering at a new font/cell
- // size. Re-measure and resize the PTY so the server reflows to the new column
- // count. The refit's own 150ms debounce gives the WebView a frame to apply the
- // new fontSize before we measure the resulting cell metrics.
+ // Why: on text-size change the refit's 150ms debounce lets the WebView apply the new fontSize before we re-measure cell metrics.
const prevTextScaleRef = useRef(textScale)
useEffect(() => {
if (prevTextScaleRef.current === textScale) {
@@ -258,10 +222,7 @@ export function useTerminalViewportRefit(
scheduleViewportRefit()
}, [textScale, viewportMeasuredRef, scheduleViewportRefit])
- // Why: the terminal's measured frame width changes when a panel docks/undocks or
- // either sidebar is drag-resized — none of which touch the window dims or tab
- // strip — so the cached viewport goes stale and the PTY keeps the pre-resize
- // width. Mark un-measured and refit when the measured width changes.
+ // Why: panel dock/undock or sidebar resize changes frame width with no window/tab change, so the cached viewport goes stale.
const prevFrameWidthRef = useRef(terminalFrameWidth)
useEffect(() => {
if (prevFrameWidthRef.current === terminalFrameWidth) {
@@ -284,8 +245,7 @@ export function useTerminalViewportRefit(
},
[viewportMeasuredRef, scheduleViewportRefit]
)
- // Why: notify imperatively so layout churn does not rerender the full session;
- // a height change during typing is coalesced into one refit after keyboard close.
+ // Why: notify imperatively so layout churn doesn't rerender the full session.
const notifyTerminalFrameHeight = useCallback(
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
[notifyFrameHeightRefitEvent]
@@ -310,8 +270,7 @@ export function useTerminalViewportRefit(
if (!shouldRefit) {
return
}
- // Why: the cached grid can match while the host PTY changed in background;
- // reasserting equal dimensions is the convergence signal after iOS resume.
+ // Why: cached grid can match while the host PTY changed in background; reassert equal dims to converge after iOS resume.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
})
@@ -325,11 +284,9 @@ export function useTerminalViewportRefit(
if (previous === 'connected' || connState !== 'connected') {
return
}
- // Why: an in-place desktop upgrade may add updateViewport; reconnect is the
- // narrow boundary where an old-host method_not_found cache becomes stale.
+ // Why: an in-place desktop upgrade may add updateViewport; reconnect is where the cached method_not_found goes stale.
updateViewportCapabilityRef.current = 'unknown'
- // Why: reconnect can restore a PTY whose host-side size changed while the
- // socket was down, so equal cached dimensions still need reassertion.
+ // Why: reconnect can restore a PTY resized while the socket was down, so equal cached dims still need reassertion.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index e9d188b74ef..a4d9c350c47 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -1,5 +1,4 @@
-// xterm.js WebView document + default Tokyonight theme. Extracted from
-// TerminalWebView.tsx to keep that file within the max-lines budget.
+// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
@@ -38,15 +37,7 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
brightWhite: '#c0caf5'
}
-// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
-// positioning designed for the desktop's terminal dimensions (~150+ cols).
-// We initialize xterm at the desktop's exact cols/rows so those escape codes
-// render correctly, then use a measured CSS transform: scale() to fit the
-// canvas into the phone viewport. The scale is computed after xterm opens
-// by measuring the rendered surface width, not hardcoded, so it adapts to
-// any terminal column count (80, 150, 200+). All touch gestures (scroll,
-// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
-// behavior, so they work correctly with the CSS scale transform.
+// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `
@@ -1892,6 +1883,5 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
`
-// Why: WebView treats source identity as page identity on some platforms; keep
-// parent/session re-renders from reloading xterm and forcing fresh snapshots.
+// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm.
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx
index d9e07be4033..80ec7f11c1f 100644
--- a/mobile/src/transport/client-context.tsx
+++ b/mobile/src/transport/client-context.tsx
@@ -1,14 +1,5 @@
-// 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.
+// Single shared RpcClient per host, collapsing the old per-screen WebSocket connections.
+// Design: docs/mobile-shared-client-per-host.md.
import {
createContext,
useCallback,
@@ -42,40 +33,28 @@ export type RpcClientContextValue = {
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.
+ // Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
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).
+ // Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice.
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext(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.
+ // Why: entries in a ref so state changes don't re-render the whole tree; propagation goes through per-host listener Sets.
const storeRef = useRef>(new Map())
const stateListenersRef = useRef void>>>(new Map())
const allHostsListenersRef = useRef 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.
+ // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
const pendingOpensRef = useRef(new HostClientOpenRegistry())
- // 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.
+ // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef>(new Map())
function notifyHostState(hostId: string, state: ConnectionState) {
@@ -118,28 +97,20 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const pendingOpen = pendingOpensRef.current.register(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.
+ // Why: prefer the primed cache so we don't serialize a second Keychain pass 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.
+ // Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) {
- // Why: returning silently leaves mounted screens on a permanent
- // spinner (STA-1511) — surface 'disconnected' so they can render
- // their waiting/retry affordance instead.
+ // Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
@@ -160,9 +131,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
try {
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} 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.
+ // Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
@@ -191,12 +160,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
- // 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.
+ // Synchronous get-or-open: returns an existing client immediately, else kicks off an async open and returns null this tick.
const acquire = useCallback(
(hostId: string, host?: HostProfile): RpcClient | null => {
if (host) {
@@ -207,9 +171,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
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.
+ // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
void openEntry(hostId).then((entry) => {
if (!entry) {
return
@@ -227,14 +189,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
- // 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).
+ // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) {
@@ -246,10 +201,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
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."
+ // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
@@ -318,16 +270,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
- // 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.
+ // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const store = storeRef.current
@@ -339,9 +282,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
- // Why: nudge every live client when the OS signals the link may be back
- // (foreground, network restored/switched) so sessions recover without an
- // app restart (issue #5049).
+ // Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049).
useEffect(() => {
return subscribeConnectionRevivalTriggers(() => {
for (const entry of storeRef.current.values()) {
@@ -392,9 +333,7 @@ export function useRpcClientContext(): RpcClientContextValue {
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.
+// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
@@ -419,17 +358,13 @@ export function useHostClient(hostId: string | undefined): {
return
}
setState(next)
- // Why: the client materialises after an async open, and forceReconnect
- // swaps in a fresh client object. Re-read on every state change so a
- // mounted screen never keeps driving a stale (closed) client.
+ // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
- // Why: closeHost deletes the entry without a replacement; holding the
- // closed client would let screens keep issuing requests that can never
- // resolve (STA-1511). Null it so they render disconnected states.
+ // Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511).
clientRef.current = null
force((n) => n + 1)
}
@@ -450,9 +385,7 @@ export function useHostClient(hostId: string | undefined): {
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.
+// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
@@ -510,9 +443,7 @@ export function useAllHostClients(hostIds: string[]): Array<{
}, [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.
+// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export function useCloseHost(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.closeHost
@@ -524,9 +455,7 @@ export function useForceReconnect(): (hostId: string) => Promise {
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.
+// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts
diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts
index a404379e557..61ed6742d12 100644
--- a/mobile/src/transport/host-store.ts
+++ b/mobile/src/transport/host-store.ts
@@ -23,16 +23,12 @@ import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upg
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
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.
+// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
const WEB_TOKEN_KEY_PREFIX = 'orca:web-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.
+// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device.
+// Reads/writes stay silent (no biometric prompt) because we don't request access control flags.
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
@@ -46,8 +42,7 @@ function webTokenKey(hostId: string): string {
}
async function readDeviceToken(hostId: string): Promise {
- // Why: Expo SecureStore has no working web backend; keep this fallback
- // web-only so native builds still keep pairing tokens in the keychain.
+ // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
@@ -76,18 +71,10 @@ async function deleteHostCredentials(hostId: string): Promise {
await deleteMobileRelayDirectUpgradeJournal(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).
+// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
const tokenCache = new Map()
let inflightLoad: Promise | null = null
-// Why: rename / lastConnected / remove / save all RMW the same hosts JSON.
-// Without a queue, concurrent writers re-read a stale snapshot and the last
-// setItem wins — resurrecting a removed host or dropping a rename.
+// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
let hostListMutation: Promise = Promise.resolve()
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
@@ -100,9 +87,7 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
return null
}
return parsed.flatMap((item) => {
- // 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.
+ // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
@@ -115,11 +100,9 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
}
export async function loadHosts(): Promise {
- // Why: writers hold the mutation chain across their full RMW; wait so a
- // load right after rename/remove does not race a half-written list.
+ // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
- // Why: deduplicate concurrent loadHosts() calls so multiple screens
- // mounting simultaneously share one Keychain read pass.
+ // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
if (inflightLoad) {
return inflightLoad
}
@@ -152,16 +135,11 @@ async function doLoadHosts(): Promise {
try {
fetched = await readDeviceToken(stored.id)
} 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.
+ // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
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.
+ // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
continue
}
token = fetched
@@ -187,8 +165,7 @@ export async function resolvePairingHostIdentity(
publicKeyB64: string,
newHostId: string
): Promise<{ id: string; name: string }> {
- // Why: one durable read both preserves an existing identity and names a new host,
- // avoiding duplicate cards and a second serial storage read before connecting.
+ // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
await hostListMutation
const hosts = await readStoredHostsForMutation()
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
@@ -201,8 +178,7 @@ async function readStoredHostsForMutation(): Promise {
try {
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
if (!parsed) {
- // Why: refuse to RMW over unreadable payload — treating it as [] would
- // wipe the durable host list on the next rename/remove/save.
+ // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
throw new Error('host list storage unreadable')
}
return parsed
@@ -260,8 +236,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
}
if (index >= 0) {
updatedExistingHost = true
- // Why: affected installs may already contain duplicate rows; an authoritative
- // save is the safe point to collapse them to the preserved host id.
+ // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
return hosts
.filter(({ id }) => !duplicateHostIds.has(id))
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
@@ -272,11 +247,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
}
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), 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.
+ // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
if (validated.endpoints) {
@@ -293,8 +264,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
overlayRemovalIds.push(stored.id)
}
if (overlayRemovalIds.length > 0) {
- // Why: reusing an id for direct-only re-pairing must not retain routing
- // metadata from the host's previous transport state.
+ // Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state.
await removeMobileRelayHostOverlays(overlayRemovalIds)
}
for (const duplicateHostId of duplicateHostIds) {
@@ -313,11 +283,9 @@ export async function removeHost(hostId: string): Promise {
try {
await removeMobileRelayHostOverlay(hostId)
} catch {
- // The missing legacy base is authoritative, so a retained overlay cannot
- // resurrect this host and can be cleaned on a later explicit retry.
+ // Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry.
}
- // Why: await only the durable cleanup intent (AsyncStorage). Native keychain
- // delete can reject or stall and must not freeze removeHost / the UI.
+ // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
} catch {
@@ -333,10 +301,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
return retryPendingHostCredentialCleanups(deleteHostCredentials)
}
-// Why: Edit host can change name and endpoint together; a single
-// mutateStoredHosts pass keeps both fields committed atomically so a
-// mid-save failure can never persist one change without the other, and a
-// host removed mid-edit throws consistently instead of silently no-oping.
+// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other.
export async function updateHostNameAndEndpoint(
hostId: string,
updates: { name?: string; endpoint?: string }
@@ -368,9 +333,7 @@ export async function updateLastConnected(hostId: string): Promise {
return next
})
} catch {
- // Why: last-connected is a best-effort timestamp and callers fire it with
- // `void`. Swallow unreadable-storage failures so they don't surface as an
- // unhandled promise rejection.
+ // Why: best-effort timestamp fired with void; swallow so unreadable storage doesn't reject.
}
}
diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts
index 5420ba679a6..578e1a1ba0b 100644
--- a/mobile/src/transport/rpc-client.ts
+++ b/mobile/src/transport/rpc-client.ts
@@ -79,74 +79,36 @@ export type RpcClient = {
viewport: { cols: number; rows: number }
) => 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.
+ // 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
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".
+ // Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
- // Why: app-resume hook. Android/iOS can kill the TCP path or park the
- // reconnect loop while the app is backgrounded; callers invoke this on
- // AppState 'active' so the session recovers without an app restart.
+ // Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover.
notifyForeground: () => void
close: () => void
}
-// 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).
+// Why: tiered backoff — fast early entries recover blips; the slow tail avoids burning a SYN every 4s on an unreachable desktop.
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
-// Why: cap fast auto-retry once we're clearly unreachable for a long time.
-// With the tiered backoff above this is ≈ 6 minutes of continuous
-// failure before the UI surfaces 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 slows to the trickle cadence.
+// Why: ≈6 min of failure before the re-pair banner; MUST stay aligned with connection-health.ts UNREACHABLE_ATTEMPTS.
const GIVE_UP_AFTER_ATTEMPTS = 12
-// Why: past the cap the loop must never park permanently. A wedged
-// Tailscale/VPN tunnel produces no AppState or network-type transition
-// (still Wi-Fi, still "online"), so no revival nudge ever fires — users
-// had to toggle Tailscale off/on just to force one. A slow trickle dial
-// self-heals once the tunnel recovers while staying cheap: one TCP
-// attempt per 90s, foreground-only (iOS/Android suspend JS timers in
-// the background).
+// Why: never park past the cap — a wedged VPN fires no AppState/network nudge to revive it, so trickle-dial every 90s to self-heal.
const TRICKLE_RECONNECT_DELAY_MS = 90_000
-// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead.
-// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair
-// while the desktop still listed it as paired with a valid token — a transient
-// rejection (mid-session resume race, a stale frame after background) latched
-// the terminal auth-failed state permanently. Retry the full handshake this
-// many times with a clean reconnect before declaring auth dead. A genuinely
-// revoked token is rejected on every attempt and converges to auth-failed in
-// seconds; a one-off glitch self-heals without the user re-pairing.
+// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
const AUTH_RETRY_BUDGET = 3
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.
+// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes.
const WEBSOCKET_CONNECTING_STATE = 0
-// Why: RN auto-pongs WebSocket pings natively, so JS needs an app-level
-// liveness probe to detect half-open sockets. Any inbound app traffic after
-// a probe starts proves the link is alive; otherwise an unanswered probe
-// force-closes the socket so the reconnect path can recover.
+// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
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.
+ // Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route).
onLog?: ConnectionLogSink
}
@@ -185,14 +147,10 @@ export function connect(
let handshakeTimer: ReturnType | null = null
let activityProbeTimer: ReturnType | null = null
let intentionallyClosed = false
- // Why: consecutive auth rejections since the last successful connect. We
- // tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed
- // so a transient rejection doesn't force a needless re-pair. Reset to 0 on
- // every 'connected'.
+ // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
let authRejectionCount = 0
let lastConnectedAt: number | null = null
- // Why: cheap diagnostics for RN/OkHttp process-state poisoning: do retry
- // attempts differ, is anything inbound, and are closes instant or slow?
+ // Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing).
let lastInboundAt: number | null = null
let inboundSequence = 0
let lastWsClosedAt: number | null = null
@@ -200,7 +158,6 @@ export function connect(
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.
let sharedKey: Uint8Array | null = null
const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64)
@@ -218,9 +175,7 @@ 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.
+ // Diagnostic: dwell time in the current state, for spotting "stuck in connecting/reconnecting".
let stateEnteredAt = Date.now()
function rejectConnectWaiters(reason: string) {
@@ -250,8 +205,7 @@ export function connect(
})
if (next === 'connected') {
lastConnectedAt = Date.now()
- // Why: a clean handshake proves the token is valid — clear the auth
- // retry budget so a future isolated rejection gets the full budget again.
+ // Why: a clean handshake proves the token is valid — reset the auth retry budget.
authRejectionCount = 0
for (const waiter of connectWaiters.splice(0)) {
if (waiter.timeout) {
@@ -269,8 +223,7 @@ 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.
+ // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port.
function redactedEndpoint(ep: string): string {
try {
const m = ep.match(/^wss?:\/\/([^/]+)/i)
@@ -288,17 +241,13 @@ export function connect(
return Promise.reject(new Error('Client closed'))
}
if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
- // Why: past the retry cap the loop only trickles every 90s — callers
- // must fail fast rather than hang on a host that's been unreachable
- // for minutes. A trickle dial that succeeds flips state to 'connected'
- // and later requests go through normally.
+ // Why: past the cap the loop only trickles every 90s — fail fast instead of hanging on a long-unreachable host.
return Promise.reject(new Error('Connection retry limit reached'))
}
return new Promise((resolve, reject) => {
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
if (timeoutMs !== undefined) {
- // Why: explicit per-request timeouts must include offline/reconnect
- // waiting, not only the RPC after the socket becomes connected.
+ // Why: per-request timeouts must cover offline/reconnect waiting, not just the RPC after connect.
waiter.timeout = setTimeout(
() => {
const index = connectWaiters.indexOf(waiter)
@@ -328,11 +277,7 @@ export function connect(
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.
+ // Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck.
wsCount: wsConstructionCounter,
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
@@ -354,8 +299,7 @@ export function connect(
if (ws === openingWs) {
return false
}
- // Why: React Native can deliver callbacks from a timed-out socket after
- // reconnect has swapped in a replacement; stale events must not mutate it.
+ // Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them.
console.log('[net] stale ws event ignored', {
eventName,
state,
@@ -364,9 +308,7 @@ export function connect(
return true
}
- // 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...".
+ // Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
connectTimer = setTimeout(() => {
connectTimer = null
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
@@ -396,9 +338,7 @@ export function connect(
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
- // doesn't compromise past or future sessions.
+ // Why: fresh ephemeral keypair per connection provides forward secrecy.
const ephemeral = generateKeyPair()
const hello = JSON.stringify({
type: 'e2ee_hello',
@@ -437,8 +377,7 @@ export function connect(
lastInboundAt = Date.now()
const raw = typeof rawData === 'string' ? rawData : null
- // Why: during handshaking, e2ee_ready is plaintext because it precedes
- // encrypted auth; e2ee_authenticated/e2ee_error are encrypted.
+ // Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted.
if (state === 'handshaking') {
if (raw === null) {
return
@@ -481,9 +420,7 @@ export function connect(
removeStreamListener(id)
continue
}
- // Why: setState('connected') notifies UI listeners synchronously;
- // a listener may subscribe and send immediately before this
- // reconnect replay loop resumes.
+ // Why: a UI listener notified synchronously by setState('connected') may already have sent this stream — skip it.
if (stream.sent) {
continue
}
@@ -515,8 +452,7 @@ export function connect(
return
}
- // Why: guard against decrypt with an invalid key — sharedKey can be null
- // after destroy() or if a message arrives during a reconnect race.
+ // Why: sharedKey can be null after destroy() or a reconnect race — don't decrypt with an invalid key.
if (!sharedKey || sharedKey.length !== 32) {
return
}
@@ -553,10 +489,7 @@ export function connect(
}
recordValidatedInboundTraffic()
- // Why: a mid-session unauthorized may be a transient glitch, not a dead
- // pairing (issue #5200). handleAuthRejection retries the handshake a few
- // times before latching auth-failed, while still bounding churn via the
- // budget so a genuinely revoked token doesn't reconnect forever.
+ // Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed.
if (!response.ok && response.error.code === 'unauthorized') {
handleAuthRejection('Unauthorized — pairing may be revoked')
return
@@ -646,18 +579,12 @@ export function connect(
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.
+ // Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/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: statically imported (not closure-built) — an earlier hot-reload
- // bug came from a stale closure capturing a half-loaded module.
+ // Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: e?.code,
@@ -682,9 +609,7 @@ export function connect(
if (ignoreStaleSocketEvent('error')) {
return
}
- // 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.
+ // Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause.
const e = event as { message?: string } | undefined
const errEvent = describeSocketEvent(event)
console.log('[net] ws.onerror', {
@@ -734,11 +659,7 @@ export function connect(
scheduleReconnect()
}
- // Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session
- // unauthorized RPC) may be transient — issue #5200. Retry the full handshake
- // up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off
- // glitch self-heals instead of forcing the user to re-pair. A genuinely
- // revoked token fails every retry and latches auth-failed within seconds.
+ // Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed.
function handleAuthRejection(reason: string): void {
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
@@ -754,9 +675,7 @@ export function connect(
'Authentication rejected',
`Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
)
- // Why: close the current socket but DON'T set intentionallyClosed —
- // we want handleSocketClosed to route into the reconnect path so the
- // token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs.
+ // Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake.
const closing = ws
ws = null
sharedKey = null
@@ -782,19 +701,11 @@ export function connect(
}
function scheduleReconnect() {
- // Why: spinning fast reconnects forever drains battery and floods logs
- // when the host is genuinely unreachable (wrong IP, port closed,
- // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a
- // "Can't reach desktop, re-pair?" banner and the loop drops to the
- // 90s trickle cadence instead of parking — a permanently parked loop
- // could only be revived by an AppState/network transition, which a
- // wedged VPN tunnel never produces.
+ // Why: past the cap, trickle (never park) — a parked loop only revives on a network transition a wedged VPN never produces.
const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS
let delay: number
if (pastGiveUpCap) {
- // Why: the counter holds at the cap — connection-health thresholds and
- // the "Can't reach desktop" verdict key off attempts >= 12, and a
- // successful open resets it to 0 anyway.
+ // Why: hold the counter at the cap — connection-health's "Can't reach desktop" verdict keys off attempts >= 12.
delay = TRICKLE_RECONNECT_DELAY_MS
rejectConnectWaiters('Connection retry limit reached')
} else {
@@ -824,10 +735,7 @@ export function connect(
}
}
- // 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).
+ // Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects.
function runActivityProbe() {
if (state !== 'connected' || !ws) {
return
@@ -966,8 +874,7 @@ export function connect(
removeStreamListener(id)
return
}
- // Why: sent streams may still reply with `ready`; keep a tombstone so we
- // can immediately unsubscribe. Queued streams never reached desktop.
+ // Why: a sent stream may still reply `ready`; keep the tombstone to unsubscribe it (queued streams never reached the desktop).
if (!stream.sent) {
removeStreamListener(id)
}
@@ -1013,11 +920,7 @@ export function connect(
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.
+ // Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever.
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: ws.readyState
@@ -1125,9 +1028,7 @@ export function connect(
if (pendingBrowserScreencastRequestId && pendingBrowserScreencastRequestId !== id) {
disposeBrowserScreencastStream(pendingBrowserScreencastRequestId)
}
- // Why: browser screencast frames are connection-scoped and carry no
- // stream id. Wait for the replacement stream's ready response before
- // routing frames, so in-flight old-page pixels are dropped.
+ // Why: screencast frames carry no stream id, so route only after the new stream's ready to drop stale old-page pixels.
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
}
@@ -1140,9 +1041,7 @@ export function connect(
removeStreamListener(id)
}
} 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.
+ // Registered now; the outbound subscribe is (re-)sent once the channel reaches 'connected'.
console.log('[net] subscribe queued — waiting for connected', { method, state })
}
@@ -1157,12 +1056,7 @@ export function connect(
return
}
if (stream?.method === 'terminal.subscribe') {
- // Why: the runtime registers cleanup under the composite key
- // `${terminal}:${clientId}` so two phones subscribing to the same
- // terminal handle don't evict each other. Echo that composite key
- // back on unsubscribe; also include `client.id` so the server can
- // reconstruct it if a stale build emits a bare-handle id. See
- // docs/mobile-presence-lock.md.
+ // Why: server keys cleanup by composite `${terminal}:${clientId}` so two phones don't evict each other. See docs/mobile-presence-lock.md.
const unsubscribeParams = buildTerminalUnsubscribeParams(stream.params)
if (unsubscribeParams) {
sendEncrypted({
@@ -1211,20 +1105,14 @@ export function connect(
return
}
if (state === 'connected') {
- // Why: the OS can kill the TCP path while the app is backgrounded
- // without delivering onclose, leaving a half-open socket that
- // blackholes input. Probe now so death is detected in ≤8s instead
- // of waiting out the 20s interval (issue #5049).
+ // Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049).
console.log('[net] foreground — probing live connection')
startActivityProbe()
runActivityProbe()
return
}
if (state === 'reconnecting') {
- // Why: while backgrounded the retry loop may be sitting on a 60s
- // backoff or 90s trickle timer. Returning to the foreground is a
- // strong user signal — restart with a fresh attempt budget
- // immediately instead of waiting out the timer.
+ // Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer.
console.log('[net] foreground — restarting reconnect loop', {
attempt: reconnectAttempt,
hadTimer: !!reconnectTimer
diff --git a/package.json b/package.json
index e5734083688..77b11ed0f1c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.146-rc.0",
+ "version": "1.4.147-rc.4",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts
index fc285c56440..cdafcd66743 100644
--- a/src/cli/handlers/orchestration.ts
+++ b/src/cli/handlers/orchestration.ts
@@ -10,19 +10,13 @@ import { RuntimeClientError } from '../runtime-client'
import { getTerminalHandle } from '../selectors'
import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary'
-// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence
-// budget and generates only ~40 lines per 10 min wait — enough to assure the
-// parent process the subprocess is alive without flooding logs. See design
-// doc §3.4.
+// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
-// Why: test-only escape hatch so subprocess tests can verify the feature in
-// under 10 s rather than needing a full 15 s silence window. Production users
-// should never set this — there is no surface documentation. A bogus value
-// falls back to the default rather than disabling the keepalive.
+// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default.
function resolveKeepaliveIntervalMs(): number {
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
if (!raw) {
@@ -40,16 +34,12 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void {
const interval = setInterval(() => {
const payload = {
_keepalive: true,
- // Why: retain the old marker for scripts filtering merged stderr while
- // callers migrate to the unambiguous _keepalive field.
+ // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive.
_heartbeat: true,
elapsedMs: Date.now() - startedAt,
deadlineMs: deadlineMs ?? null
}
- // Why: `process.stderr.write` is line-flushed per-call in Node, whereas a
- // fully-buffered writer would hold all keepalive lines until exit and
- // silently defeat the whole point of the ping. Subprocess test asserts
- // this by reading stderr incrementally. See §3.4.
+ // Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4.
process.stderr.write(`${JSON.stringify(payload)}\n`)
}, resolveKeepaliveIntervalMs())
if (typeof interval.unref === 'function') {
@@ -58,8 +48,7 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void {
return () => clearInterval(interval)
}
-// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a
-// clear enum-aware error before the generic RPC Zod "Missing --status" message.
+// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message.
const TASK_STATUS_VALUES = [
'pending',
'ready',
@@ -114,8 +103,7 @@ function getOptionalStructuredMessagePayload(
'Use either --payload or structured payload flags, not both.'
)
}
- // Why: raw JSON arguments are fragile in Windows PowerShell; these flags let
- // workers send parseable orchestration payloads without shell-specific quoting.
+ // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting.
const payload: Record = {}
if (taskId) {
payload.taskId = taskId
@@ -152,9 +140,7 @@ async function resolveOrchestrationTerminalHandle(
const envHandle = process.env.ORCA_TERMINAL_HANDLE
if (envHandle && envHandle.length > 0) {
if (flagName === 'from' && options.validateEnvHandle) {
- // Why: long-lived shells can retain an ORCA_TERMINAL_HANDLE after the
- // runtime remints the pane handle; do not bake that stale id into
- // coordinator preambles.
+ // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles.
const live = await isLiveTerminalHandle(envHandle, client)
if (!live) {
const reminted = await resolveOrchestrationPaneTerminalHandle(client)
@@ -184,8 +170,7 @@ async function resolveTaskCreatorTerminalHandle(
live = await isLiveTerminalHandle(envHandle, client)
} catch (err) {
if (isOptionalTaskCreatorHandleError(err)) {
- // Why: creator handles are best-effort lineage metadata; graph
- // unavailability should not block task creation itself.
+ // Why: creator handles are best-effort lineage metadata; graph unavailability must not block task creation.
return undefined
}
throw err
@@ -242,8 +227,7 @@ async function resolveOrchestrationPaneTerminalHandle(
return undefined
}
try {
- // Why: pane key reminting preserves the caller identity; focus-based
- // active-terminal fallback can point at a different pane.
+ // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane.
const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', {
paneKey
})
@@ -362,17 +346,11 @@ export const ORCHESTRATION_HANDLERS: Record = {
!getOptionalStringFlag(flags, 'from') &&
!process.env.ORCA_TERMINAL_HANDLE
) {
- // Why: focus is not lifecycle authority; injected dispatches carry an
- // explicit worker handle so an identity-less subprocess must fail closed.
+ // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker.
throwNoActiveSenderTerminal()
}
- // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
- // probe (terminal.show throws runtime_unavailable in the exact mid-restart
- // window worker_done must survive) and no pane remint (pre-payload-
- // authority runtimes require from === the equally stale assignee_handle,
- // and coordinator replies route to the sender row while the worker's own
- // `check` reads its env-handle inbox).
+ // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle).
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call('orchestration.send', {
from,
@@ -383,14 +361,12 @@ export const ORCHESTRATION_HANDLERS: Record = {
priority: getOptionalStringFlag(flags, 'priority'),
threadId: getOptionalStringFlag(flags, 'thread-id'),
payload: getOptionalStructuredMessagePayload(flags),
- // Why: the pane key is the remint-stable sender identity the runtime
- // verifies lifecycle ownership against; older runtimes strip it.
+ // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it.
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
devMode: isDevCliInvocation()
})
if ('message' in result.result && result.result.lifecycle?.action === 'rejected') {
- // Why: a persisted diagnostic is not a successful lifecycle signal;
- // the non-zero status prevents workers from treating it as completion.
+ // Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such.
process.exitCode = 1
}
printResult(result, json, (r) => {
@@ -407,9 +383,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
'orchestration check': async ({ flags, client, cwd, json }) => {
const wait = flags.has('wait')
const peek = flags.has('peek')
- // Why: enforce mode exclusivity client-side too — an older runtime strips
- // the unknown `peek` param and would otherwise execute --unread --peek as
- // a destructive mark-read.
+ // Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read.
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
throw new RuntimeClientError(
'invalid_argument',
@@ -419,13 +393,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
- // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce
- // no output for ~2 min (shorter on the non-interactive path). Emit a
- // keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is
- // active so the parent process can see the subprocess is still alive.
- // Stderr rather than stdout so stdout stays a single final JSON payload,
- // and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work
- // (jq refuses `#`-prefixed lines). See design doc §3.4.
+ // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4.
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
type CheckResult = {
messages: MessageSummary[]
@@ -436,10 +404,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
try {
result = await client.call('orchestration.check', {
terminal,
- // Why: --peek also sends unread:false so runtimes that predate the
- // peek param (which their non-strict schema strips) degrade to the
- // non-consuming all-messages mode instead of the destructive
- // mark-read default; the read filter below restores peek semantics.
+ // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read.
unread: flags.has('unread') ? true : peek ? false : undefined,
peek: peek ? true : undefined,
all: flags.has('all') ? true : undefined,
@@ -455,19 +420,14 @@ export const ORCHESTRATION_HANDLERS: Record = {
const rawRowCount = result.result.messages.length
const unreadOnly = result.result.messages.filter((m) => m.read !== 1)
const removedReadRows = unreadOnly.length !== rawRowCount
- // Why: read rows in a peek response are the pre-peek-runtime signature
- // (its schema stripped `peek` and it ran the all mode). Such a runtime
- // returned instead of blocking, so honoring --wait is impossible —
- // failing beats silently returning empty before the deadline.
+ // Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly.
if (wait && removedReadRows && unreadOnly.length === 0) {
throw new RuntimeClientError(
'peek_wait_unsupported',
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
)
}
- // Why: pre-peek runtimes cap the all mode at the newest 100 rows, so a
- // full page means older unread messages may have been cut off. Warn on
- // stderr so stdout stays a single JSON payload.
+ // Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr.
if (removedReadRows && rawRowCount >= 100) {
console.error(
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
@@ -477,8 +437,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
...result,
result: {
...result.result,
- // Why: a pre-peek runtime builds `formatted` from all rows; drop it
- // when the read filter removed any so output matches the peek set.
+ // Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set.
...(removedReadRows ? { formatted: undefined } : {}),
messages: unreadOnly,
count: unreadOnly.length
@@ -521,8 +480,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
if (r.count === 0) {
return 'No messages.'
}
- // Why: default output omits body/payload for at-a-glance sweeps; --full
- // prints them verbatim so callers can audit without parsing --json.
+ // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing.
return r.messages
.map((m) => {
const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`
@@ -577,9 +535,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
ready: flags.has('ready') ? true : undefined,
brief: brief ? true : undefined
})
- // Why: current runtimes abbreviate server-side (rows carry
- // spec_truncated) so full specs never cross the wire; older runtimes
- // strip the brief param and need the client-side fallback.
+ // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback.
const needsClientAbbreviation =
brief && result.result.tasks.some((task) => task.spec_truncated === undefined)
const output = needsClientAbbreviation
@@ -671,18 +627,10 @@ export const ORCHESTRATION_HANDLERS: Record = {
timeoutMs: parsedTimeoutMs,
from
},
- // Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but
- // the RPC transport has its own 60s default timeout that would fire
- // first. Extend the per-call timeout by a small grace window so the
- // RPC doesn't abort before the runtime's internal timeout resolves.
+ // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
{ timeoutMs: timeoutMs + 5_000 }
)
- // Why: deliberate bypass of `printResult`. `--json` on `ask` emits a
- // single-line bare JSON object (no RPC envelope, no multi-line pretty-
- // print) so workers can pipe `orca orchestration ask … --json | jq -r
- // .answer` without reaching into a `result` envelope. This diverges from
- // every other orchestration verb; called out in the commit message and
- // guarded by a unit test in orchestration.test.ts.
+ // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
if (json) {
console.log(JSON.stringify(result.result))
} else if (result.result.answer !== null) {
@@ -698,8 +646,7 @@ export const ORCHESTRATION_HANDLERS: Record = {
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
const showPreamble = flags.has('preamble') ? true : undefined
- // Why: resolve --from when previewing so the preamble embeds a real
- // coordinator handle, matching what an actual dispatch would produce.
+ // Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch.
const from = showPreamble
? await resolveCoordinatorTerminalHandle(flags, cwd, client)
: undefined
diff --git a/src/main/active-view-persistence-boundary.test.ts b/src/main/active-view-persistence-boundary.test.ts
new file mode 100644
index 00000000000..26bb25ac70e
--- /dev/null
+++ b/src/main/active-view-persistence-boundary.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { getActiveViewPreferenceFile } from './active-view-preference'
+
+const testState = { dir: '' }
+
+vi.mock('electron', () => ({
+ app: { getPath: () => testState.dir },
+ safeStorage: {
+ isEncryptionAvailable: () => true,
+ encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
+ decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').replace('encrypted:', '')
+ }
+}))
+
+vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
+vi.mock('./ssh/ssh-config-parser', () => ({
+ loadUserSshConfig: vi.fn(),
+ sshConfigHostsToTargets: vi.fn()
+}))
+
+describe('active-view persistence boundary', () => {
+ beforeEach(() => {
+ testState.dir = mkdtempSync(join(tmpdir(), 'orca-active-view-boundary-'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ rmSync(testState.dir, { recursive: true, force: true })
+ })
+
+ it('persists a view switch without changing the global durable snapshot', async () => {
+ vi.useFakeTimers()
+ vi.resetModules()
+ const { Store } = await import('./persistence')
+ const dataFile = join(testState.dir, 'orca-data.json')
+ const store = new Store({ dataFile })
+ store.flush()
+ const durableBefore = readFileSync(dataFile, 'utf-8')
+
+ store.updateUI({ activeView: 'settings' })
+ vi.advanceTimersByTime(1_000)
+ await store.waitForPendingWrite()
+
+ const preferenceFile = getActiveViewPreferenceFile(dataFile)
+ const preferencePayload = readFileSync(preferenceFile, 'utf-8')
+ expect(Buffer.byteLength(preferencePayload)).toBeLessThan(64)
+ expect(JSON.parse(preferencePayload)).toEqual({ activeView: 'settings' })
+ expect(readFileSync(dataFile, 'utf-8')).toBe(durableBefore)
+ expect(store.getUI().activeView).toBe('settings')
+
+ const reloaded = new Store({ dataFile })
+ expect(reloaded.getUI().activeView).toBe('settings')
+ })
+})
diff --git a/src/main/active-view-preference.test.ts b/src/main/active-view-preference.test.ts
new file mode 100644
index 00000000000..972009f722c
--- /dev/null
+++ b/src/main/active-view-preference.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { ActiveViewPreference, getActiveViewPreferenceFile } from './active-view-preference'
+
+describe('ActiveViewPreference', () => {
+ let dir: string
+ let dataFile: string
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'orca-active-view-'))
+ dataFile = join(dir, 'orca-data.json')
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ it('migrates the legacy durable-state value into the profile sidecar', async () => {
+ vi.useFakeTimers()
+ const preference = new ActiveViewPreference(dataFile, 'tasks')
+
+ expect(preference.get()).toBe('tasks')
+ expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
+
+ // The renderer reasserts its hydrated value once persistence is ready.
+ expect(preference.set('tasks')).toBe(false)
+ vi.advanceTimersByTime(100)
+ await preference.waitForPendingWrite()
+
+ expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
+ activeView: 'tasks'
+ })
+ })
+
+ it('coalesces rapid switches into one tiny preference write', async () => {
+ vi.useFakeTimers()
+ const preference = new ActiveViewPreference(dataFile, 'terminal')
+
+ preference.set('settings')
+ vi.advanceTimersByTime(50)
+ preference.set('automations')
+ vi.advanceTimersByTime(50)
+ expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
+
+ vi.advanceTimersByTime(50)
+ await preference.waitForPendingWrite()
+
+ expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
+ activeView: 'automations'
+ })
+ })
+
+ it('flushes synchronously for an immediate graceful exit', () => {
+ vi.useFakeTimers()
+ const preference = new ActiveViewPreference(dataFile, 'terminal')
+
+ preference.set('settings')
+ preference.flushOrThrow()
+
+ expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
+ activeView: 'settings'
+ })
+ vi.advanceTimersByTime(100)
+ expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
+ activeView: 'settings'
+ })
+ })
+
+ it('ignores an invalid sidecar and invalid updates', () => {
+ writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"unknown"}', 'utf-8')
+ const preference = new ActiveViewPreference(dataFile, 'not-a-view')
+
+ expect(preference.get()).toBe('terminal')
+ expect(preference.set('also-not-a-view')).toBe(false)
+ expect(preference.get()).toBe('terminal')
+ })
+
+ it('rejects inherited object keys from a corrupt sidecar', () => {
+ // Why: `constructor`/`__proto__` are truthy under `in`; the sidecar must not
+ // treat them as a valid view and leave the main surface blank.
+ writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"constructor"}', 'utf-8')
+ const preference = new ActiveViewPreference(dataFile, 'tasks')
+
+ expect(preference.get()).toBe('tasks')
+ expect(preference.set('__proto__')).toBe(false)
+ expect(preference.get()).toBe('tasks')
+ })
+})
diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts
new file mode 100644
index 00000000000..beed983aef4
--- /dev/null
+++ b/src/main/active-view-preference.ts
@@ -0,0 +1,136 @@
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { TopLevelView } from '../shared/types'
+import { isTopLevelView } from '../shared/top-level-view'
+
+const ACTIVE_VIEW_FILE_NAME = 'active-view.json'
+const SAVE_DEBOUNCE_MS = 100
+
+type ActiveViewFile = {
+ activeView: TopLevelView
+}
+
+export function getActiveViewPreferenceFile(dataFile: string): string {
+ return join(dirname(dataFile), ACTIVE_VIEW_FILE_NAME)
+}
+
+function readActiveView(file: string): TopLevelView | null {
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial
+ return isTopLevelView(parsed.activeView) ? parsed.activeView : null
+ } catch {
+ return null
+ }
+}
+
+function serializeActiveView(activeView: TopLevelView): string {
+ return `${JSON.stringify({ activeView } satisfies ActiveViewFile)}\n`
+}
+
+export class ActiveViewPreference {
+ private readonly file: string
+ private activeView: TopLevelView
+ private persistedActiveView: TopLevelView | null
+ private writeTimer: ReturnType | null = null
+ private pendingWrite: Promise | null = null
+ private writeGeneration = 0
+
+ constructor(dataFile: string, legacyActiveView: unknown) {
+ this.file = getActiveViewPreferenceFile(dataFile)
+ const storedActiveView = readActiveView(this.file)
+ const fallbackActiveView = isTopLevelView(legacyActiveView) ? legacyActiveView : 'terminal'
+ this.activeView = storedActiveView ?? fallbackActiveView
+ this.persistedActiveView = storedActiveView
+ }
+
+ get(): TopLevelView {
+ return this.activeView
+ }
+
+ set(value: unknown): boolean {
+ if (!isTopLevelView(value)) {
+ return false
+ }
+ const changed = value !== this.activeView
+ this.activeView = value
+ if (
+ value !== this.persistedActiveView ||
+ this.writeTimer !== null ||
+ this.pendingWrite !== null
+ ) {
+ this.scheduleSave()
+ }
+ return changed
+ }
+
+ private scheduleSave(): void {
+ this.writeGeneration += 1
+ if (this.writeTimer) {
+ clearTimeout(this.writeTimer)
+ }
+ this.writeTimer = setTimeout(() => {
+ this.writeTimer = null
+ const generation = this.writeGeneration
+ const activeView = this.activeView
+ const previousWrite = this.pendingWrite ?? Promise.resolve()
+ const nextWrite = previousWrite
+ .then(() => this.writeAsync(activeView, generation))
+ .catch((error) => {
+ console.error('[active-view] Failed to persist preference:', error)
+ })
+ .finally(() => {
+ if (this.pendingWrite === nextWrite) {
+ this.pendingWrite = null
+ }
+ })
+ this.pendingWrite = nextWrite
+ }, SAVE_DEBOUNCE_MS)
+ }
+
+ private async writeAsync(activeView: TopLevelView, generation: number): Promise {
+ const tmpFile = `${this.file}.${process.pid}.${generation}.tmp`
+ let renamed = false
+ try {
+ await mkdir(dirname(this.file), { recursive: true })
+ await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8')
+ // Why: keep the generation guard and the swap synchronous (no await between),
+ // or a concurrent flushOrThrow could rename a newer view that this stale write
+ // then clobbers, restoring the prior view on next launch.
+ if (generation !== this.writeGeneration) {
+ return
+ }
+ renameSync(tmpFile, this.file)
+ renamed = true
+ this.persistedActiveView = activeView
+ } finally {
+ if (!renamed) {
+ await rm(tmpFile).catch(() => {})
+ }
+ }
+ }
+
+ flushOrThrow(): void {
+ if (this.writeTimer) {
+ clearTimeout(this.writeTimer)
+ this.writeTimer = null
+ }
+ const asyncWriteWasInFlight = this.pendingWrite !== null
+ this.writeGeneration += 1
+ this.pendingWrite = null
+ if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) {
+ return
+ }
+ mkdirSync(dirname(this.file), { recursive: true })
+ const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp`
+ writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8')
+ renameSync(tmpFile, this.file)
+ this.persistedActiveView = this.activeView
+ }
+
+ async waitForPendingWrite(): Promise {
+ if (this.pendingWrite) {
+ await this.pendingWrite
+ }
+ }
+}
diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts
index d669192907f..02ff623f52f 100644
--- a/src/main/agent-hooks/first-work-branch-rename.ts
+++ b/src/main/agent-hooks/first-work-branch-rename.ts
@@ -1,8 +1,4 @@
-// Why: when first-message auto-name is on, the first time an agent starts
-// working in a freshly-created workspace we replace the auto-generated creature
-// branch (e.g. `you/Nautilus`) with a short, work-derived name. This module
-// owns the orchestration: gate on the signal, enforce the safety guardrails,
-// summarize the prompt via the configured agent, and rename.
+// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
@@ -59,10 +55,7 @@ export type FirstWorkBranchRenameDeps = {
setDisplayName: (worktreeId: string, displayName: string) => void
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise
- /** Record (or clear with null) a user-facing auto-rename generation failure
- * so the sidebar can show a "rename failed" badge instead of silent retries.
- * `failureOutput` carries the bounded full CLI output for local on-demand
- * display; omitted/null replaces any stale capture. */
+ /** Record (or clear with null) an auto-rename failure for the sidebar "rename failed" badge; `failureOutput` carries bounded CLI output for on-demand display. */
setRenameError: (
worktreeId: string,
error: string | null,
@@ -74,12 +67,7 @@ export type FirstWorkBranchRenameDeps = {
onRenamed: (repoId: string) => void
}
-// Why: `inFlight` guards against concurrent runs while generation (seconds) is
-// pending; `settled` short-circuits worktrees we've reached a definitive verdict
-// on (renamed, or permanently ineligible) so we never re-probe git on later
-// `working` events — important because the hook fires every turn for every pane.
-// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT
-// settled, so the real first prompt can still succeed on a later event.
+// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later).
const inFlightWorktreeIds = new Set()
const settledWorktreeIds = new Set()
export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500
@@ -91,8 +79,7 @@ export function resetFirstWorkBranchRenameState(): void {
}
function rememberSettledWorktreeId(worktreeId: string): void {
- // Why: the app can see unbounded worktree ids over a long session; evicting
- // oldest entries trades a rare re-probe for bounded process memory.
+ // Why: worktree ids are unbounded over a long session; evict oldest to bound memory (costs a rare re-probe).
settledWorktreeIds.delete(worktreeId)
settledWorktreeIds.add(worktreeId)
while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) {
@@ -115,8 +102,7 @@ export async function maybeAutoRenameBranchOnFirstWork(
if (!deps.getSettings().autoRenameBranchFromWork) {
return
}
- // Resolve the worktree from the tab (authoritative) rather than the
- // agent-echoed worktreeId, which is not present on every hook event.
+ // Resolve from the tab (authoritative); the agent-echoed worktreeId isn't present on every event.
const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId
const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId
if (!worktreeId) {
@@ -132,37 +118,27 @@ export async function maybeAutoRenameBranchOnFirstWork(
}
inFlightWorktreeIds.add(worktreeId)
try {
- // `settled` true means a definitive verdict (renamed or permanently
- // ineligible); false means a transient bail that should retry later.
+ // settled = definitive verdict (renamed/ineligible); false = transient bail to retry later.
const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps)
if (settled) {
rememberSettledWorktreeId(worktreeId)
}
} catch (error) {
- // Why: best-effort, opt-in convenience. A failure must never disrupt the
- // agent the user actually launched, so swallow after logging.
+ // Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging.
console.warn('[auto-branch-rename] rename attempt failed:', error)
} finally {
inFlightWorktreeIds.delete(worktreeId)
}
}
-/**
- * Returns true when a definitive verdict was reached (renamed, or permanently
- * ineligible — so stop re-probing), false for a transient bail worth retrying.
- */
+/** Returns true for a definitive verdict (renamed or permanently ineligible → stop re-probing), false for a transient bail worth retrying. */
async function runAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps
): Promise {
- // Why: when the feature appears not to fire, the question is always "which
- // gate bailed?" — this names the gate so a single log line answers it.
- // `stop` = permanent (don't retry); `retry` = transient (try again later).
- // `clearError` clears a stale "rename failed" badge when the worktree reaches
- // a benign final state (ineligible, user-renamed, or pushed) after an earlier
- // transient generation failure raised one — otherwise it would stick forever.
+ // `stop` = permanent skip (logs which gate bailed); `retry` = transient; `clearError` drops a stale "rename failed" badge on a benign final state.
const stop = (reason: string, clearError = false): true => {
if (clearError) {
deps.setRenameError(worktreeId, null)
@@ -188,9 +164,7 @@ async function runAutoRename(
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
- // Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs
- // carry a synthetic `::workspace:` suffix that is not a real directory,
- // so resolve to the backing folder or Git spawns against a nonexistent cwd.
+ // Why: folder-workspace ids carry a synthetic `::workspace:` suffix that's not a real dir; resolve to the backing folder for Git's cwd.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
if (!repo || !parsed) {
return stop('unresolved repo or worktree id')
@@ -223,8 +197,7 @@ async function runAutoRename(
return stop(`branch "${currentBranch}" already has an upstream`, true)
}
if (upstreamProbe.outcome === 'probe-failed') {
- // Why: settling an unreadable probe as "has upstream" made this failure
- // silent and permanent via the settled cache (issue #7808).
+ // Why: settling an unreadable probe as "has upstream" made this silent + permanent via the settled cache (issue #7808).
const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim()
deps.setRenameError(
worktreeId,
@@ -237,8 +210,7 @@ async function runAutoRename(
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo)
if (!resolvedParams.ok) {
- // Why: a generation-step failure (vs a benign skip) is user-actionable, so
- // surface it on the card rather than leaving a silent "rename pending".
+ // Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card.
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
@@ -256,17 +228,14 @@ async function runAutoRename(
target
)
if (!generated.success) {
- // Transient: the agent may be momentarily busy/unreachable — let a later
- // event retry rather than permanently leaving the creature name.
- // A user-canceled generation isn't a failure to surface, so skip the badge.
+ // Transient failure: retry on a later event; skip the badge for a user-canceled generation.
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
}
return retry(`generation failed: ${generated.error}`)
}
- // Re-validate after generation (it can take seconds): the branch must be the
- // same unpublished creature branch we started from, or we leave it alone.
+ // Re-validate after generation (takes seconds): bail if the branch changed or was published meanwhile.
const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branchNow !== currentBranch) {
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
@@ -283,14 +252,12 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: (await resolveLocalGitUsername(repo.path)) || null
- // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip
- // it so it doesn't double-prefix the branch or leak into the display name.
+ // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing.
const slug = stripConfiguredBranchPrefix(
generated.slug,
getConfiguredBranchPrefix(settings, username)
)
- // Prefix-only model output strips to empty; renaming with it would just
- // re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip.
+ // Prefix-only output strips to empty; renaming would just re-add the prefix (`tmchow/tmchow`), so skip.
if (!slug) {
return stop('model produced only the configured prefix', true)
}
@@ -301,8 +268,7 @@ async function runAutoRename(
currentBranch
)
if (!newBranch || newBranch === currentBranch) {
- // Generation succeeded but yielded no distinct name — terminal and benign,
- // so clear any stale failure badge a prior transient attempt left behind.
+ // No distinct name is terminal and benign, so clear any stale failure badge from a prior transient attempt.
return stop(`no distinct unique branch name for slug "${slug}"`, true)
}
@@ -310,12 +276,9 @@ async function runAutoRename(
? provider.renameCurrentBranch(worktreePath, newBranch)
: renameCurrentBranch(exec, newBranch))
- // resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so
- // derive the sidebar name and on-disk folder from the *resolved* branch leaf,
- // not the pre-suffix slug, to keep branch, display, and folder aligned.
+ // resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug.
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
- // Keep the sidebar name in sync with the branch — but only when it is still
- // the auto-generated creature name, so a name the user typed is left alone.
+ // Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone.
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf })
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
@@ -326,10 +289,7 @@ async function runAutoRename(
// A successful rename clears any stale generation-failure surfaced earlier.
deps.setRenameError(worktreeId, null)
- // Align the on-disk folder with the new branch leaf. Best-effort and local-only:
- // a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is
- // and must never undo the branch/display rename that already landed. Runs after
- // setDisplayName so the new display name rides along into the migrated identity.
+ // Best-effort local-only folder align, after setDisplayName so the new name rides into the migrated identity; a failure must never undo the landed rename.
let folderRenamed = false
if (deps.renameWorktreeFolder) {
try {
@@ -339,9 +299,7 @@ async function runAutoRename(
}
}
- // A successful folder rename already invalidated caches and pushed a
- // worktrees:changed carrying the id mapping; a second onRenamed would only
- // trigger a redundant renderer re-list. Otherwise notify for the branch rename.
+ // A folder rename already notified the renderer, so a second onRenamed would just re-list redundantly.
if (!folderRenamed) {
deps.onRenamed(repo.id)
}
diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts
index c73e1536e4f..e0688b44345 100644
--- a/src/main/agent-hooks/installer-utils.ts
+++ b/src/main/agent-hooks/installer-utils.ts
@@ -40,16 +40,11 @@ export type HooksConfig = {
[key: string]: unknown
}
-// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell
-// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a
-// hook the agent host itself runs in case that transport budget is bypassed.
-// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets
-// coexist by design (#4633).
+// Why: host-level backstop timeout for status hooks, independent of the curl --max-time and Copilot's timeoutSec (#4633).
export const MANAGED_HOOK_TIMEOUT_SECONDS = 10
export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000
-// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude,
-// Codex, Gemini, Droid, Grok, Command Code, Devin).
+// Nested command hook for the Claude-shaped `hooks: [...]` schema (Claude, Codex, Gemini, Droid, Grok, Command Code, Devin).
export function buildManagedCommandHook(
command: string,
timeout = MANAGED_HOOK_TIMEOUT_SECONDS
@@ -57,8 +52,7 @@ export function buildManagedCommandHook(
return { type: 'command', command, timeout }
}
-// Direct command definition used by schemas that put `command` on the
-// definition itself (Cursor's documented top-level shape).
+// Direct command definition for schemas that put `command` on the definition itself (Cursor's top-level shape).
export function buildManagedCommandDefinition(command: string): HookDefinition {
return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
@@ -70,18 +64,12 @@ export {
type HooksJsonSnapshot
} from './hooks-json-read'
-// Why: callers in install/remove need to match not just the exact current
-// managed command, but also stale entries pointing at old script paths — e.g.
-// from a previous dev build with a different Electron userData dir, or a
-// parallel dev/prod install. Matching by the managed script's file name
-// (under any `agent-hooks/` directory) lets a fresh install sweep those
-// without touching unrelated user-authored hooks.
+// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs.
export function createManagedCommandMatcher(
scriptFileName: string
): (command: string | undefined) => boolean {
const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '')
- // Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX
- // installs use .sh. A platform switch must still sweep stale Orca hooks.
+ // Why: installs use .cmd/.ps1 (Windows) or .sh (SSH/POSIX); match all so a platform switch still sweeps stale hooks.
const needles = [
`agent-hooks/${scriptFileName}`,
`agent-hooks/${scriptStem}.cmd`,
@@ -111,8 +99,7 @@ function decodePowerShellEncodedCommand(command: string): string | null {
}
}
-// Why: prod, dev, and parallel Orca instances must write the same managed
-// settings entry instead of racing between per-userData script paths.
+// Why: prod/dev/parallel Orca instances must write the same managed entry, not race between per-userData script paths.
export function getSharedManagedScriptPath(scriptFileName: string): string {
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
}
@@ -121,16 +108,9 @@ function quotePosixShellString(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
-// 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 ""` on every tool call, which exits 127 and surfaces
-// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
-// transcript. Guarding for a regular readable executable file makes a broken
-// install a silent no-op without hiding failures from a script that starts.
+// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
export function wrapPosixHookCommand(scriptPath: string, env: Record = {}): 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.
+ // Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
const quoted = quotePosixShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
@@ -154,8 +134,7 @@ export function wrapWindowsHookCommand(
scriptPath: string,
env: Record = {}
): string {
- // Why: the encoded launcher protects paths across Windows hook shells and
- // owns stdin when a stale config points at a missing managed script.
+ // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script.
const quoted = quotePowerShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
@@ -168,14 +147,7 @@ export function wrapWindowsHookCommand(
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
- // Why: Codex/Antigravity/Devin launch the hook command as a program (argv[0]),
- // NOT through cmd.exe, so the launcher must be a single directly-spawnable
- // token. The bare .cmd path is exactly that. A cmd-builtin `if exist …`
- // launcher is unspawnable — its argv[0] is `if` — so it fails every hook with
- // exit 1 (#8430 regression). A stale/missing script therefore surfaces a normal
- // launch failure here; the missing-script stdin drain lives on the encoded
- // fallback below, which needs a real interpreter for spaces/metacharacters
- // anyway and cannot be reached by a cmd-builtin drain without breaking spawn.
+ // Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430).
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
@@ -183,17 +155,15 @@ export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
const bashPath = scriptPath.replaceAll('\\', '/')
- // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly;
- // unsafe paths stay encoded and the fast path gains a missing-file drain.
+ // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
: wrapWindowsHookCommand(scriptPath)
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
- // Why: Codex runs these hooks inline on every turn. PowerShell startup alone
- // makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
- // Qualify curl so a repo-local curl.exe cannot hijack hook payloads.
+ // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path.
+ // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads.
return [
`"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`,
' --connect-timeout 0.5 --max-time 1.5 ^',
@@ -209,12 +179,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin
].join('\r\n')
}
-// Why: status hooks fire up to 6× per turn; spawning PowerShell per post adds
-// ~300ms of interpreter startup each, which Codex 0.140's synchronous "Running
-// hook" rows make visible. curl.exe (Windows 10 1803+) posts the same
-// form fields as the POSIX hook and reads the raw payload from stdin via
-// `--data-urlencode payload@-`, so UTF-8 (e.g. CJK prompts) survives byte-for-
-// byte without the code-page translation that previously forced PowerShell.
+// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both.
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
return [
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
@@ -286,8 +251,7 @@ export function hookDefinitionHasManagedCommand(
)
}
-// Why: temp+rename so concurrent Orca instances writing this shared path can't
-// produce a torn script that an in-flight `/bin/sh ` would source.
+// Why: temp+rename so concurrent writers can't leave a torn script for an in-flight /bin/sh to source.
export function writeManagedScript(scriptPath: string, content: string): void {
const dir = dirname(scriptPath)
mkdirSync(dir, { recursive: true })
@@ -308,9 +272,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeScriptWithAclRetry(tmpPath, content)
- // Why: chmod before rename so the canonical path is never visible in a
- // unreadable/non-executable state; wrapPosixHookCommand's guards would
- // silently skip the hook in that window.
+ // Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook.
if (process.platform !== 'win32') {
chmodSync(tmpPath, 0o755)
}
@@ -326,8 +288,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
}
}
-// Why: on Windows, write may fail with EPERM if the target directory has a
-// restrictive DACL. Grant an explicit ACL on EPERM and retry once.
+// Why: a restrictive directory DACL makes writes fail with EPERM on Windows; grant an ACL and retry once.
function writeScriptWithAclRetry(scriptPath: string, content: string): void {
try {
writeFileSync(scriptPath, content, 'utf-8')
@@ -354,15 +315,8 @@ export function writeHooksJson(
const dir = dirname(writePath)
mkdirSync(dir, { recursive: true })
- // Why: write to a temp file then rename so a crash or disk-full mid-write
- // leaves the original untouched. This is the only safe way to update a
- // config file the user may have hand-edited.
- //
- // Why randomUUID: Date.now() alone collides when two install() calls fire in
- // the same millisecond targeting the same dir (e.g. a future caller that
- // installs multiple agents sharing a config dir, or rapid reinstalls from
- // the settings UI). A collision would corrupt one of the two writes. The
- // UUID suffix makes the tmp path unique per call.
+ // Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
+ // Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
const serialized = `${JSON.stringify(config, null, 2)}\n`
const existingMode =
@@ -378,9 +332,7 @@ export function writeHooksJson(
return
}
} catch {
- // Fall through to the normal write path — a read error here is not
- // worth failing the install for; the atomic write below will either
- // succeed or throw loudly.
+ // Fall through to the normal write path; a read error isn't worth failing the install for.
}
}
diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts
index f1aba1cb09f..0a75205623c 100644
--- a/src/main/agent-hooks/server.test.ts
+++ b/src/main/agent-hooks/server.test.ts
@@ -157,9 +157,7 @@ describe('AgentHookServer listener replay', () => {
state: 'working',
prompt: 'review loop',
agentType: 'claude',
- // Why: a working pane can be child-driven (lead already idle).
- // Ctrl+C does not stop background children, so no terminal done
- // may be inferred while one is still running.
+ // Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here.
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }]
}
},
@@ -2571,9 +2569,7 @@ describe('AgentHookServer listener replay', () => {
}
})
- // Why: agent-status-over-SSH §3 — ingestRemote must run the same warn-once
- // cross-build diagnostics the local HTTP path runs, so a remote source of
- // genuinely stale hooks emits the same signal locally.
+ // Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally.
it('runs warn-once env/version diagnostics on relay-forwarded events', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
@@ -2631,9 +2627,7 @@ describe('AgentHookServer listener replay', () => {
'conn-1'
)
expect(warn.mock.calls.length).toBe(warnsAfterFirst)
- // Why: pin both invariants — warn-once dedupe AND fanout still fires for
- // the second event. Without the second assertion, a future refactor that
- // drops the second event silently would still leave warn-count unchanged.
+ // Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone.
expect(listener).toHaveBeenCalledTimes(2)
} finally {
server.stop()
@@ -3843,9 +3837,7 @@ describe('Claude hook normalization', () => {
})
it('PostToolUse for an unknown tool surfaces the name without input', () => {
- // Why: we use a per-tool allowlist to decide which field to preview.
- // Tools we do not recognize render as name-only rather than guessing at
- // a field, which avoids noisy/misleading previews (e.g. an opaque ID).
+ // Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID).
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
@@ -3860,9 +3852,7 @@ describe('Claude hook normalization', () => {
})
it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => {
- // Why: TaskUpdate's tool_input (e.g. { task_id: "3", status: "in_progress" })
- // has no meaningful preview — rendering "3" is actively confusing. The
- // allowlist approach leaves toolInput undefined for unlisted tools.
+ // Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined.
const result = _internals.normalizeHookPayload(
'claude',
buildBody({
@@ -4144,9 +4134,7 @@ describe('Claude hook normalization', () => {
})
it('finds an assistant reply that sits past the first chunk boundary', () => {
- // Why: a turn with many large tool_result entries pushes the final text
- // reply well past the first 64 KB chunk; the chunked scan should keep
- // reading backward until it finds it.
+ // Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it.
const filler = 'x'.repeat(70_000)
const lines = [
{ role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } },
@@ -4208,8 +4196,7 @@ describe('Claude hook normalization', () => {
}),
'production'
)
- // Stop event has no tool fields of its own — merged snapshot should still
- // carry the earlier PreToolUse values.
+ // Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values.
const stop = _internals.normalizeHookPayload(
'claude',
buildBody({ hook_event_name: 'Stop' }),
@@ -4236,10 +4223,7 @@ describe('Codex hook normalization', () => {
})
it('PreToolUse surfaces tool name + input preview and stays in working state', () => {
- // Why: Codex's PreToolUse is NOT an approval prompt — it fires for every
- // tool call. We map it to `working` (never `waiting`) and use it only to
- // give the dashboard a live readout during the gap between prompt and
- // Stop. Real approval signals flow through PermissionRequest.
+ // Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4255,10 +4239,7 @@ describe('Codex hook normalization', () => {
})
it('PermissionRequest maps to waiting and surfaces the pending tool input', () => {
- // Why: Codex asks for user attention through PermissionRequest. Orca's
- // sidebar red dot depends on this becoming `waiting`; treating it like
- // PreToolUse would leave the pane looking busy while it is blocked on the
- // user.
+ // Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4275,9 +4256,7 @@ describe('Codex hook normalization', () => {
})
it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => {
- // Why: UserPromptSubmit is a turn-boundary event; any tool_name on it
- // would be leftover noise and should not leak into the working-state
- // preview. Tool extraction is gated to PreToolUse/PostToolUse.
+ // Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -4419,11 +4398,7 @@ describe('OpenCode hook normalization', () => {
})
it('SessionBusy does NOT clear the cached user prompt', () => {
- // Why: OpenCode emits the user's MessagePart (message.updated) *before*
- // SessionBusy fires — the session goes idle→busy only after OpenCode begins
- // processing the prompt. So the cached prompt at SessionBusy is the current
- // turn's prompt, not the previous turn's. Clearing on SessionBusy would
- // clobber the data the dashboard needs to render for this turn.
+ // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard.
_internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }),
@@ -4458,12 +4433,7 @@ describe('OpenCode hook normalization', () => {
})
it('AskUserQuestion maps to waiting', () => {
- // Why: OpenCode emits `question.asked` when the agent uses an ask-the-user
- // tool (distinct from `permission.asked`, which blocks on tool approval).
- // Both leave the agent idle-but-waiting on a human, so both must render
- // the same red "needs attention" indicator. Without this mapping the pane
- // silently stays in `working` and the user has no visual cue that the
- // agent is waiting on them.
+ // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`.
const result = _internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'AskUserQuestion' }),
@@ -4514,10 +4484,7 @@ describe('OpenCode hook normalization', () => {
})
it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => {
- // Why: plugin builds installed before the throttle/cap fix re-post the
- // full accumulated reply on every streamed part update. The listener must
- // bound the text so each event's status compare, IPC fanout, and renderer
- // store update stay O(cap) instead of O(reply length).
+ // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap).
const assistant = _internals.normalizeHookPayload(
'opencode',
buildBody({
@@ -4529,9 +4496,7 @@ describe('OpenCode hook normalization', () => {
)
expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000)
- // Why: prompt has always been single-line-capped at 200 by
- // normalizeAgentStatusObject; this asserts the oversized input still
- // flows through without blowing past that bound.
+ // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound.
const user = _internals.normalizeHookPayload(
'opencode',
buildBody({
@@ -4656,8 +4621,7 @@ describe('Cursor hook normalization', () => {
}),
'production'
)
- // Why: keeping toolName set would let the compact sidebar show the tool
- // instead of the failure text, hiding the error from the user.
+ // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'file not found'
@@ -5193,8 +5157,7 @@ describe('Pi hook normalization', () => {
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.
+ // Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage.
expect(result?.payload.lastAssistantMessage).toBeUndefined()
})
@@ -5214,8 +5177,7 @@ describe('Pi hook normalization', () => {
buildBody({ hook_event_name: 'session_shutdown' }),
'production'
)
- // Why: Pi also emits shutdown when reloading or replacing its in-process
- // session while the PTY stays alive; only agent_end proves turn completion.
+ // Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion.
expect(result).toBeNull()
})
@@ -5324,8 +5286,7 @@ describe('Copilot hook normalization', () => {
}),
'production'
)
- // Why: keeping toolName set would let the compact sidebar show the tool
- // instead of the failure text, hiding the error from the user.
+ // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'command not found'
@@ -5715,9 +5676,7 @@ describe('Endpoint file lifecycle', () => {
await server.start({ env: 'production', userDataPath })
try {
const filePath = server.endpointFilePath!
- // Why: mask off type/setuid bits so we assert only the rwx octet that
- // writeFileSync(mode:0o600) sets. A leaky umask at dir-create time can
- // leave group/other bits on the *parent* dir but not on the file itself.
+ // Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir.
const mode = statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
} finally {
@@ -5739,19 +5698,11 @@ describe('Endpoint file lifecycle', () => {
const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN
// Path is stable (so PTYs stamped before restart can still find the file)
expect(secondPath).toBe(firstPath)
- // But contents are refreshed with the new token (and port) — that is the
- // whole point of the design: survivors reading a stale-env file reach the
- // live server. Why token-first: the token is randomUUID()-minted per
- // start(), so it is guaranteed to differ across restarts. The port comes
- // from listen(0) and the kernel can legitimately reassign the same
- // ephemeral port, so asserting port-inequality would be a latent flake.
+ // Contents refresh with a new token so stale-env survivors reach the live server.
expect(secondToken).toBeTruthy()
expect(secondToken).not.toBe(firstToken)
const contents = readFileSync(secondPath!, 'utf8')
- // Why: token-based content check is the rewrite signal. A strict
- // "contents does NOT contain firstPort" assertion would flake on the
- // (rare but legitimate) case where listen(0) reuses the same ephemeral
- // port across restarts. The token is randomUUID() and cannot collide.
+ // Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check.
expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`)
expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`)
expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`)
@@ -5761,13 +5712,7 @@ describe('Endpoint file lifecycle', () => {
})
it('leaves the endpoint file in place on stop()', async () => {
- // Why: stop() deliberately does NOT unlink the endpoint file. A stale file
- // points at a dead port — the fail-open path (hook POSTs silently fail,
- // same as pre-endpoint-file). Unlinking would introduce a TOCTOU race with a
- // concurrent Orca instance sharing userData that could rewrite the file
- // between our token check and unlink. The next successful start()
- // overwrites the file atomically; tmp-file orphan hygiene is handled by
- // the sweep inside writeEndpointFile().
+ // Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU).
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
const filePath = server.endpointFilePath!
@@ -5825,9 +5770,7 @@ describe('Endpoint file lifecycle', () => {
})
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => {
- // Why: the endpoint file is opt-in via start({ userDataPath }). In tests
- // and in the packaged main-process path where userData is unset for any
- // reason, hooks should fall back to the v1 behavior (no ENDPOINT key).
+ // Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key).
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
@@ -5846,10 +5789,7 @@ describe('Endpoint file lifecycle', () => {
})
it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => {
- // Why: writeEndpointFile() writes to a unique tmp path then renames. A crash
- // between write and rename leaves an orphan tmp; the sweep inside
- // writeEndpointFile() must drop ones older than 5 min without touching
- // fresh ones (a concurrent writer's in-flight tmp).
+ // Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp.
const dir = join(userDataPath, 'agent-hooks')
mkdirSync(dir, { recursive: true })
const staleTmp = join(dir, '.endpoint-999-stale.tmp')
@@ -5870,12 +5810,7 @@ describe('Endpoint file lifecycle', () => {
})
it('refuses to write the endpoint file when a value contains shell metacharacters', async () => {
- // Why: every value written is sourced as shell. The isShellSafeEndpointValue
- // allowlist must reject a metacharacter-bearing value so a future caller
- // cannot command-inject via the sourced file. `env` is the only caller-
- // provided field we can easily poison from a test — feed it a semicolon
- // and assert the file is not written and buildPtyEnv() omits the ENDPOINT
- // key (gated on endpointFileWritten).
+ // Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection.
const server = new AgentHookServer()
await server.start({ env: 'bad;value', userDataPath })
try {
@@ -5960,10 +5895,7 @@ describe('Endpoint file lifecycle', () => {
try {
const filePath = server.endpointFilePath!
const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT
- // Why: sources the file in a subshell and echoes the resulting env var,
- // exactly as the managed hook script does at runtime. If the file shape
- // ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters
- // without quoting), this test catches it before users do.
+ // Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do.
const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`])
.toString()
.trim()
@@ -5989,9 +5921,7 @@ describe('Last-status persistence', () => {
return join(userDataPath, 'agent-hooks', 'last-status.json')
}
- // Why: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use
- // a recent-but-not-Date.now() timestamp in fixtures so the tests assert
- // hydration behavior rather than racing the wall clock.
+ // Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock.
function recentTs(offsetMs = 0): number {
return Date.now() - 60 * 60 * 1000 + offsetMs
}
@@ -6305,8 +6235,7 @@ describe('Last-status persistence', () => {
subagents: undefined
})
])
- // Why: make the migration one-time; otherwise every launch reparses and
- // re-prunes the same persisted idle rows.
+ // Why: migration must be one-time, else every launch re-prunes the same persisted idle rows.
const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(persisted.entries[PANE].payload.subagents).toBeUndefined()
} finally {
@@ -6614,9 +6543,7 @@ describe('Last-status persistence', () => {
entries: {
[TAB_A_PANE]: {
paneKey: TAB_A_PANE,
- // Why: deliberately divergent — paneKey says tab-A, the entry
- // claims tab-B. Sanitizer must drop rather than hydrate this
- // inconsistent row.
+ // Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it.
tabId: 'tab-B',
receivedAt: recentTs(),
stateStartedAt: recentTs(-1000),
@@ -6676,14 +6603,10 @@ describe('Last-status persistence', () => {
server.flushStatusPersistSync()
const firstMtime = statSync(lastStatusPath()).mtimeMs
- // Why: a no-op clearPaneState on a paneKey not in the cache is a
- // mutation site that should NOT trigger a redundant write. (clear was
- // designed to bail when nothing was evicted.)
+ // Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted).
server.clearPaneState(makePaneKey('non-existent', LEAF_5))
server.flushStatusPersistSync()
- // Touch back to the same mtime would let the test pass spuriously, so
- // assert no rewrite happened by checking that mtime is unchanged after
- // a forced sync flush.
+ // Assert no rewrite happened: mtime unchanged after a forced sync flush.
const secondMtime = statSync(lastStatusPath()).mtimeMs
expect(secondMtime).toBe(firstMtime)
} finally {
@@ -6706,8 +6629,7 @@ describe('Last-status persistence', () => {
} finally {
server.stop()
}
- // Why: file written even though we never explicitly flushed before stop —
- // stop() must synchronously drain the pending trailing-debounced timer.
+ // Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed.
expect(existsSync(lastStatusPath())).toBe(true)
const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me')
@@ -7114,9 +7036,7 @@ describe('AgentHookServer ingestRemote', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
- // Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
- // constructing an obviously-invalid payload — `ingestRemote` is the trust
- // boundary we're testing, not the parser.
+ // Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser.
server.ingestRemote(
{
paneKey: PANE,
@@ -7281,10 +7201,7 @@ describe('AgentHookServer ingestRemote', () => {
})
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
- // Why: the relay normally normalizes the payload on the wire, but a buggy
- // or malicious relay could forward an over-cap field. ingestRemote must
- // re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
- // cap (200 chars) is enforced at the trust boundary.
+ // Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary.
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index 4c5d4c80cc1..d02642b1111 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -1,15 +1,5 @@
/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */
-// Why: this module is the Orca-main-process adapter for the shared
-// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The
-// listener internals (request parsing, payload normalization, endpoint-file
-// writing, validation) live in `shared/` so the relay can host the same
-// pipeline on the remote without dragging Electron in. This file owns:
-// - the loopback HTTP socket + bearer-token auth
-// - the IPC fanout (setListener / lastStatusByPaneKey replay)
-// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded
-// events (see docs/design/agent-status-over-ssh.md §5)
-// - the on-disk last-status cache (`last-status.json`) that survives
-// Orca restart so retained dashboard rows reappear on relaunch
+// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -74,14 +64,7 @@ import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn
export type { AgentHookSource }
-// Why: server-process-only enrichment of the shared event payload. The shared
-// listener emits `AgentHookEventPayload` (the bare event shape). For
-// persistence and the dashboard's "did the agent transition since I last
-// looked?" comparison, we attach `receivedAt` (when the latest event arrived
-// for this pane) and `stateStartedAt` (when the current state first appeared).
-// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload`
-// is the declared map value, and the extra fields ride along untouched because
-// the shared module only writes/clears, never reads.
+// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
@@ -103,42 +86,24 @@ type PaneKeyAliasEntry = {
authorityVerified: boolean
}
-// Why: name of the on-disk cache that survives Orca restart. Lives next to
-// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
-// restart artifacts stay co-located.
+// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
-// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
-// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
-// developer who upgrades from an in-flight branch sees an empty hydration
-// once instead of partially-typed legacy entries. New file format; never
-// shipped to users at v1. A mismatched version is treated as a corrupt file
-// (silent empty hydration).
+// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
const LAST_STATUS_FILE_VERSION = 2
-// Why: trailing-edge debounce so a burst of hook events from a multi-agent
-// run produces one disk write instead of N. The latency budget matches other
-// hook-server batching; quit-time uses flushStatusPersistSync() for the
-// guaranteed final flush.
+// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush.
const STATUS_PERSIST_DEBOUNCE_MS = 250
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES)
-// Why: bound the on-disk file's growth across many sessions. PTY-teardown
-// eviction handles closed panes, but daemon-restored PTYs that never re-attach
-// and crash-recovery paths where teardown never fires can leave entries
-// pinned forever. 7 days matches the user-visible "still relevant?" horizon —
-// older entries have almost certainly been resolved or abandoned and should
-// not resurrect on hydrate.
+// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate.
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
-// Why: closed-tab suppression only needs to cover recently closed tabs — a
-// status event for a long-closed tab cannot arrive once its process/hooks are
-// gone. Bound the set so it can't grow one entry per tab close for the whole
-// session (it is otherwise only cleared at app quit).
+// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session.
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
export const PANE_KEY_ALIASES_MAX = 1024
@@ -176,8 +141,7 @@ function equivalentInterruptAgentType(
return normalizedActual === normalizedBaseline
}
-// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
-// at write/hydrate time so legacy numeric rows fail closed.
+// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
return (
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
@@ -194,16 +158,14 @@ function dropHydratedIdleClaudeSubagents(
return payload
}
const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working')
- // Why: older builds persisted finished Claude children as idle rows. Prune
- // them from the replay payload itself so restart cannot resurrect the pile.
+ // Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile.
return {
...payload,
subagents: workingSubagents.length > 0 ? workingSubagents : undefined
}
}
-// Why: the sole gate deciding whether a providerSessionOnly row is trustworthy
-// enough to keep. Shared so the hydrate and relay-ingest paths can't drift.
+// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift.
function isValidPiProviderSessionOnly(
providerSession: AgentProviderSessionMetadata | undefined,
agentType: AgentType | undefined
@@ -230,9 +192,7 @@ function sanitizeHydratedEntry(
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
return null
}
- // Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field
- // diverges from the key's tab segment is corruption (renamer bug, manual
- // edit, future shape drift). Drop instead of hydrating an inconsistent row.
+ // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row.
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
return null
}
@@ -252,8 +212,7 @@ function sanitizeHydratedEntry(
) {
return null
}
- // Why: connectionId is allowed to be null (local) or string (relay). Any
- // other shape is rejected so the post-merge typed surface stays honest.
+ // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest.
const connectionIdRaw = record.connectionId
let connectionId: string | null
if (connectionIdRaw === null || connectionIdRaw === undefined) {
@@ -307,10 +266,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
}
}
-// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`:
-// OSC payloads never carry them, and including the field would make every
-// hook-cached entry with child rows non-equivalent — the OSC ping would then
-// apply and wipe the roster. Do not reuse this for hook-path comparisons.
+// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons.
function equivalentParsedAgentStatusPayload(
a: ParsedAgentStatusPayload,
b: ParsedAgentStatusPayload
@@ -345,8 +301,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole
if (next.payload.agentType !== 'claude') {
return false
}
- // Why: a same-prompt retry is another UserPromptSubmit, while late Claude
- // progress after Ctrl+C arrives as tool lifecycle work for the old turn.
+ // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work.
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
}
@@ -378,10 +333,7 @@ function shouldKeepClaudePermissionVisible(
if (isClaudePermissionResumingApprovedTool(previous, next)) {
return false
}
- // Why: only real permission requests stay sticky across concurrent subagent
- // activity; interactive questions clear on the next working hook. Newer
- // Claude reports the AskUserQuestion wait AS a PermissionRequest, so the
- // tool name — not the event name — decides which rule applies.
+ // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides.
if (isAskUserQuestionTool(previous.payload.toolName)) {
return false
}
@@ -427,10 +379,7 @@ function isClaudePermissionResumingApprovedTool(
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
nextToolUseId !== undefined &&
!hasConflictingToolUseId &&
- // Why: subagents can share `agent_type`; a concrete agent id is the
- // strongest available signal that the permission owner resumed execution.
- // Claude's approval path omits identity but preserves the original
- // tool_use_id on PostToolUse, so that exact id is also a safe clear signal.
+ // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal.
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
sameToolName &&
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
@@ -484,8 +433,7 @@ function attachClaudePermissionToolUseId(
}
return {
...next,
- // Why: Claude emits PermissionRequest without tool_use_id, then reports the
- // approved command as PostToolUse with the original PreToolUse id.
+ // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id.
toolUseId: inheritedToolUseId
}
}
@@ -494,35 +442,24 @@ export class AgentHookServer {
private server: ReturnType | null = null
private port = 0
private token = ''
- // Why: identifies this Orca instance so hook scripts can stamp requests and
- // the server can detect dev vs. prod cross-talk. Set at start() from the
- // caller's knowledge of whether this is a packaged build.
+ // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
private env = 'production'
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
private onPaneStatusCleared: PaneStatusClearListener | null = null
private statusChangeListeners = new Set()
- // Why: directory that holds the on-disk endpoint file. Set via start()'s
- // `userDataPath` option so the class has no direct Electron dependency
- // (keeps it mockable in the vitest node environment).
+ // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
private endpointDir: string | null = null
private endpointFilePathCache: string | null = null
private endpointFileWritten = false
- // Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus
- // by paneKey). Held on the instance instead of as module-level Maps so
- // tests can spin up multiple servers without state cross-contamination.
+ // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
private state: HookListenerState = createHookListenerState()
- // Why: hydrated last-status rows are useful UI continuity, but they are not
- // evidence of live agent work in this main-process runtime.
+ // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
private runtimeObservedStatusPaneKeys = new Set()
private legacyPaneKeyAliases = new Map()
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
- // Why: full path to the on-disk last-status cache. Set in start() from
- // userDataPath. Null when the server runs without a userDataPath (e.g.
- // tests that skip the userDataPath option) — in that case, persistence is
- // a no-op and only in-memory replay applies.
+ // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
private lastStatusFilePath: string | null = null
- // Why: trailing-edge debounce timer. Captured per-instance so multiple
- // server instances in the same process (tests) don't share state.
+ // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
private statusPersistTimer: ReturnType | null = null
private assistantMessageRetryTimers = new Map>()
private promptSentDedupeByPaneKey = new Map()
@@ -530,9 +467,7 @@ export class AgentHookServer {
private closedAgentStatusTabIds = new Set()
private closedAgentStatusPaneKeys = new Set()
private connectionTimestampWatermarkById = new Map()
- // Why: identity check — skip writes when the JSON-stringified contents
- // exactly match the last successful disk write. Cheap protection against
- // re-firing trailing timers when nothing changed.
+ // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
@@ -540,15 +475,10 @@ export class AgentHookServer {
if (!listener) {
return
}
- // Why: replay is best-effort per pane so one throwing listener call can't
- // starve subsequent panes from being replayed.
+ // Why: replay is best-effort per pane so one throwing listener can't starve the rest.
for (const payload of this.state.lastStatusByPaneKey.values()) {
try {
- // Why: cache values are stored as enriched payloads (with receivedAt /
- // stateStartedAt). The map's declared element type from the shared
- // listener is the bare AgentHookEventPayload because the shared module
- // never reads from this map; only this class does, and only enriched
- // values are ever inserted.
+ // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
@@ -567,10 +497,8 @@ export class AgentHookServer {
this.onPaneStatusCleared = listener
}
- /** Snapshot of the current cached statuses, in the IPC-shaped form the
- * renderer consumes. Used by the `agentStatus:getSnapshot` IPC after
- * workspace tabs have hydrated, so the dashboard catches up on any
- * hook events that fired during startup. */
+ /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
+ * dashboard catches up on hook events that fired during startup. */
getStatusSnapshot(): AgentStatusIpcPayload[] {
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
@@ -595,14 +523,11 @@ export class AgentHookServer {
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
- // Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C
- // exits the CLI, which is handled by process/PTY lifecycle cleanup.
+ // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
if (agentType === 'droid' && request.intent === 'ctrl-c') {
return false
}
- // Why: these agents use the first Escape as a TUI/editor cancel. A single
- // Escape can leave the turn running, so only a deliberate double Escape
- // may infer an interrupted turn.
+ // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt.
if (
(agentType === 'opencode' || agentType === 'copilot') &&
request.intent === 'plain-escape' &&
@@ -610,9 +535,7 @@ export class AgentHookServer {
) {
return false
}
- // Why: input-intent inference is a fallback for a missing final hook. A strict
- // baseline match keeps a delayed timer from overwriting any newer hook,
- // including same-millisecond prompt or agent identity changes.
+ // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
@@ -623,17 +546,12 @@ export class AgentHookServer {
) {
return false
}
- // Why: a 'working' pane can be child-driven (lead already idle, background
- // subagent running). Ctrl+C at the TUI does not stop background children,
- // so inferring a terminal done here would wrongly retire live child rows;
- // their own hook events keep the row truthful instead.
+ // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
if (payload.subagents?.some((subagent) => subagent.state === 'working')) {
return false
}
- // Why: keep the listener's Claude lead-turn record in sync — a later
- // child lifecycle event would otherwise re-emit the stale pre-interrupt
- // 'working' lead state and resurrect the cancelled pane.
+ // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
}
@@ -648,8 +566,7 @@ export class AgentHookServer {
prompt: payload.prompt,
agentType,
interrupted: true,
- // Why: idle children are display state; dropping them on an inferred
- // interrupt would blank the child rows a later hook would restore.
+ // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
@@ -661,11 +578,8 @@ export class AgentHookServer {
return true
}
- /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion
- * produces no event, so the amber wait would otherwise linger until the
- * agent's next tool or turn end. The renderer reports the submit keystroke;
- * this re-validates its baseline against the cached status (a racing real
- * hook wins) and synthesizes the post-answer state. */
+ /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
+ * renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
@@ -677,12 +591,7 @@ export class AgentHookServer {
return false
}
const payload = existing.payload
- // Why: only Claude's interactive question may clear on typed input. The
- // tool name is the discriminator, not the hook event — Claude versions
- // differ on whether the AskUserQuestion wait arrives as PreToolUse or
- // PermissionRequest. Real permission waits (other tools) stay sticky until
- // the approved tool resumes — a denied or ignored permission must keep
- // demanding attention even though approving is also a keystroke.
+ // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
if (
payload.agentType !== 'claude' ||
payload.state !== 'waiting' ||
@@ -699,9 +608,7 @@ export class AgentHookServer {
) {
return false
}
- // Why: sync the listener's lead-turn record too — a later child lifecycle
- // event would otherwise re-emit the stale waiting state and resurrect the
- // dismissed question card.
+ // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card.
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
@@ -754,8 +661,7 @@ export class AgentHookServer {
}
private markTabClosedForAgentStatus(tabId: string): void {
- // Delete-then-add keeps recently closed tabs most-recent so eviction only
- // sheds the oldest ids, which can no longer receive status events.
+ // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids.
this.closedAgentStatusTabIds.delete(tabId)
this.closedAgentStatusTabIds.add(tabId)
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
@@ -881,8 +787,7 @@ export class AgentHookServer {
promptInteractionKey
})
try {
- // Why: hooks prove the user submitted a turn, but do not know which UI
- // launched the terminal; keep attribution low-cardinality and conservative.
+ // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality.
track('agent_prompt_sent', {
agent_kind: agentKind,
launch_source: 'unknown',
@@ -901,15 +806,13 @@ export class AgentHookServer {
const connectionClearWatermark = payload.connectionId
? this.connectionTimestampWatermarkById.get(payload.connectionId)
: undefined
- // Why: Date.now() can repeat across disconnect and reconnect. A remote
- // replay must sort strictly after its connection's transient clear.
+ // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear.
const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1)
if (payload.connectionId) {
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
}
if (payload.providerSessionOnly) {
- // Why: Pi session_start must replace stale turn state and survive snapshot
- // replay, but it must not emit prompt telemetry or a fabricated status.
+ // Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
const enriched = this.attachStatusTiming(payload, now)
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
@@ -953,8 +856,7 @@ export class AgentHookServer {
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
return previous
}
- // Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
- // stopped the turn. Do not let that stale same-turn event resurrect the row.
+ // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row.
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
@@ -1023,8 +925,7 @@ export class AgentHookServer {
if (!discoveryReady) {
const discovery = preparePendingGrokResultDiscovery(source, body)
if (discovery) {
- // Why: slug-group discovery can outlive the bounded transcript-flush
- // timers. Its completion must drive the first retry deterministically.
+ // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically.
void discovery
.then(() => {
if (this.server) {
@@ -1075,8 +976,7 @@ export class AgentHookServer {
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
return
}
- // Why: some agents POST Stop before their transcript/chat-history line is
- // flushed. Discovery is event-driven; subsequent content retries stay timed.
+ // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
this.applyNormalizedStatus(normalized)
}
@@ -1105,8 +1005,7 @@ export class AgentHookServer {
private boundPaneKeyAliases(): void {
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
- // Why: renderer-originated aliases are untrusted process-lifetime state;
- // insertion-order eviction bounds both memory and per-message cleanup.
+ // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup.
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
if (!oldestKey) {
break
@@ -1150,8 +1049,7 @@ export class AgentHookServer {
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
)
}
- // Why: hook status is renderer-originated evidence, not PTY ownership.
- // ID-less moves are safe only after a prior verified transfer minted an alias.
+ // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias.
return alias?.authorityVerified === true
}
@@ -1233,8 +1131,7 @@ export class AgentHookServer {
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
}
this.clearAssistantMessageRetry(previousOwnerPaneKey)
- // Why: the live process keeps posting the physical source key after detach;
- // persist one chain-safe mapping to whichever surface currently owns it.
+ // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
this.legacyPaneKeyAliases.set(physicalPaneKey, {
stablePaneKey: toPaneKey,
ptyId: normalizedPtyId,
@@ -1298,9 +1195,7 @@ export class AgentHookServer {
clearedStatusPaneKeys.add(entry.stablePaneKey)
}
if (shouldClearStablePaneKey) {
- // Why: after hydrate, legacy rows are stored under the stable key. If
- // this PTY is later proven dead before ptyPaneKey is rebuilt, alias
- // cleanup is the only path that can evict that retained status.
+ // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
@@ -1334,8 +1229,7 @@ export class AgentHookServer {
if (!stablePaneKey) {
return body
}
- // Why: migrated and detached shells keep posting an immutable physical
- // pane key; normalize both pane and tab identity to the current owner.
+ // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner.
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
}
@@ -1388,8 +1282,7 @@ export class AgentHookServer {
) {
return
}
- // Why: OSC terminal status is a runtime/model observation, not a hook
- // prompt boundary. Keep prompt-sent telemetry tied to native hooks.
+ // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
this.applyNormalizedStatus({
paneKey,
tabId,
@@ -1399,15 +1292,7 @@ export class AgentHookServer {
})
}
- /** Ingest a payload that arrived over the relay JSON-RPC channel rather
- * than the local HTTP server. `connectionId` is the SshChannelMultiplexer
- * identity Orca holds (the wire envelope carries connectionId: null and
- * Orca stamps the real value here). The relay has already normalized the
- * payload via the shared listener module, but main is still the SSH trust
- * boundary: re-run the canonical status normalizer before caching or
- * persisting anything. The `env`/`version` fields are forwarded verbatim
- * from the agent CLI's POST body on the remote and validated here so the
- * warn-once diagnostics fire for real cross-build mismatches. */
+ /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
ingestRemote(
envelope: {
paneKey: string
@@ -1429,9 +1314,7 @@ export class AgentHookServer {
},
connectionId: string
): void {
- // Why: signature says non-empty, but the wire crosses a trust boundary —
- // re-check at runtime (and trim) so a whitespace-only or empty
- // connectionId can't poison caches.
+ // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
if (typeof connectionId !== 'string') {
return
}
@@ -1442,9 +1325,7 @@ export class AgentHookServer {
if (!envelope || typeof envelope.paneKey !== 'string') {
return
}
- // Why: match the listener's HTTP path — `normalizeHookPayload` trims and
- // length-caps paneKey before caching, so the cache key here must follow
- // the same rule or remote-vs-local events for the same pane would diverge.
+ // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
const physicalPaneKey = envelope.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
@@ -1464,9 +1345,7 @@ export class AgentHookServer {
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
return
}
- // Why: mirror the HTTP path's `readStringField` behavior — trim and treat
- // empty-after-trim as undefined rather than letting a literal "" leak
- // into the event.
+ // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
const reportedTabId =
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
? envelope.tabId.trim()
@@ -1508,12 +1387,7 @@ export class AgentHookServer {
? envelope.toolAgentType.trim()
: undefined
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
- // Why: the relay is across a trust boundary; re-run the canonical
- // normalizer on the inner payload so prompt/agentType/toolName/toolInput
- // length caps, embedded-newline collapse, and the `interrupted`-only-on-
- // done invariant are enforced here too. Returns null on malformed input
- // (including invalid state), which subsumes the prior explicit state
- // check.
+ // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
const normalizedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!normalizedPayload) {
return
@@ -1524,9 +1398,7 @@ export class AgentHookServer {
) {
return
}
- // Why: run the same warn-once diagnostics the HTTP path runs (cross-build
- // version mismatch, dev-vs-prod env mismatch). Use `this.env` as the
- // expected env so the messages match what the local server produces.
+ // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
warnOnHookEnvOrVersionMismatch(this.state, {
version: envelope.version,
env: envelope.env,
@@ -1565,9 +1437,7 @@ export class AgentHookServer {
this.env = options.env
}
if (options?.userDataPath) {
- // Why: dev builds share one userData path, so callers can namespace the
- // endpoint file by dev instance while packaged builds keep the stable path
- // that lets long-lived PTYs reconnect after app restart.
+ // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
this.endpointDir = options.endpointNamespace
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
: join(options.userDataPath, 'agent-hooks')
@@ -1577,10 +1447,7 @@ export class AgentHookServer {
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
- // Why: hydrate before binding the HTTP listener so any new hook POST
- // (which goes through state.lastStatusByPaneKey.set) runs against an
- // already-populated map. The renderer later pulls this map as a snapshot
- // after workspace tabs are hydrated.
+ // Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
@@ -1597,9 +1464,7 @@ export class AgentHookServer {
return
}
- // Why: bound request time so a slow/stalled client cannot hold a socket
- // open indefinitely (slowloris-style). The hook endpoints are local and
- // should complete in well under a second.
+ // Why: bound request time so a stalled client can't hold a socket open (slowloris).
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
req.destroy()
})
@@ -1625,17 +1490,14 @@ export class AgentHookServer {
res.writeHead(204)
res.end()
} catch {
- // Why: agent hooks must fail open. The receiver returns success for
- // malformed payloads so a newer or broken hook never blocks the agent.
+ // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
res.writeHead(204)
res.end()
}
})
await new Promise((resolve, reject) => {
- // Why: swap the startup error handler on success so a later runtime
- // error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject
- // an already-settled promise or crash the main process as unhandled.
+ // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event.
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
reject(err)
@@ -1658,11 +1520,7 @@ export class AgentHookServer {
}
stop(): void {
- // Why: flush any pending debounced write to disk BEFORE we clear the
- // in-memory map. Quit-time state must be captured even if the trailing
- // timer was scheduled but had not yet fired; otherwise a multi-agent
- // run that ended its last hook event <250 ms before quit would lose
- // that final delta on relaunch.
+ // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
this.server?.close()
this.server = null
@@ -1675,9 +1533,7 @@ export class AgentHookServer {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
- // Why: intentionally do NOT delete the endpoint file on stop(). A stale
- // file points at a dead port, which matches the fail-open policy. Unlink
- // would introduce a TOCTOU race vs. a concurrent Orca instance.
+ // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
this.endpointDir = null
this.endpointFilePathCache = null
this.endpointFileWritten = false
@@ -1693,13 +1549,7 @@ export class AgentHookServer {
this.notifyStatusChangeListeners()
}
- /** Why: invoked from the renderer-driven agentStatus:drop IPC when a user
- * dismisses a still-active pane's status row. We must NOT wipe
- * lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may
- * still be alive, and the next hook event would otherwise arrive with an
- * empty prompt and missing tool snapshot until a fresh UserPromptSubmit
- * lands. clearPaneState (which wipes all three caches) is the right shape
- * only for PTY-teardown. */
+ /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
if (!this.deleteStatusEntry(paneKey)) {
return
@@ -1722,8 +1572,7 @@ export class AgentHookServer {
let statusChanged = false
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
const entry = rawEntry as EnrichedAgentHookEventPayload
- // Why: legacy/unstamped rows cannot be safely attributed to one host.
- // Leave them for normal pane teardown instead of risking cross-host loss.
+ // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown.
if (entry.connectionId !== normalizedConnectionId) {
continue
}
@@ -1732,14 +1581,11 @@ export class AgentHookServer {
}
}
if (statusChanged) {
- // Why: one disconnect can own many panes; persist and notify subscribers
- // once so cleanup cost does not fan out with the pane count.
+ // Why: persist/notify once — one disconnect can own many panes.
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
- // Why: another host can overwrite the same pane key in main's cache while
- // renderer still shows this connection's older row. Always send the
- // connection cutoff, even when no current main entry matched.
+ // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row.
this.onPaneStatusCleared?.({
transient: true,
connectionId: normalizedConnectionId,
@@ -1837,10 +1683,7 @@ export class AgentHookServer {
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
- // Why: only schedule a write when we actually evicted a status entry —
- // dropping prompt/tool caches for a pane that never produced a hook
- // event does not change the on-disk file, and skipping the write avoids
- // re-stat'ing on every dead-pane teardown.
+ // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
@@ -1876,9 +1719,7 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
- // Why: managed hooks source this file at invocation time. Packaged builds
- // use a stable file for restart handoff; dev callers pass a per-instance
- // namespace so parallel `pnpm dev` runs do not steal each other's hooks.
+ // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
@@ -1912,17 +1753,13 @@ export class AgentHookServer {
if (!this.lastStatusFilePath) {
return
}
- // Why: defensive — keeps hydrate idempotent against repeated start()
- // calls; production callers always have an empty map here, but a future
- // re-start path must not silently merge prior-session state.
+ // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
this.state.lastStatusByPaneKey.clear()
let raw: string
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
} catch (err) {
- // 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.
+ // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[agent-hooks] failed to read last-status file:', err)
}
@@ -1956,10 +1793,7 @@ export class AgentHookServer {
let hydrated = 0
let dropped = 0
let prunedLegacyClaudeSubagents = 0
- // Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so
- // entries from worktrees archived weeks ago do not pile up forever. Use
- // Date.now() once to keep the cutoff consistent across all entries this
- // tick.
+ // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
for (const [paneKey, rawEntry] of Object.entries(entries)) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
@@ -1977,16 +1811,14 @@ export class AgentHookServer {
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
if (entry.connectionId) {
- // Why: a restarted process can observe an earlier wall clock; seed
- // transport ordering so new events and clears stay after disk state.
+ // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId)
this.connectionTimestampWatermarkById.set(
entry.connectionId,
Math.max(previousWatermark ?? -1, entry.receivedAt)
)
}
- // Why: preserve only working children across restart. Live activity
- // confirms them; a later complete inventory may reap stale seeds.
+ // Why: seed only working children across restart; a later full inventory reaps stale ones.
if (entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
@@ -2005,14 +1837,10 @@ export class AgentHookServer {
)
}
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
- // Why: persist load-time pruning once so legacy idle rows do not consume
- // parse/filter work again on every launch.
+ // Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
this.runStatusPersist()
} else if (hydrated > 0) {
- // Why: prime from the raw on-disk bytes (not a re-serialization) so the
- // dedup is robust against future shape drift in serializeStatusFile.
- // Only prime when hydration was lossless — if entries were dropped
- // during sanitize, the in-memory map diverges from the on-disk bytes.
+ // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
this.lastWrittenJson = raw
}
}
@@ -2020,9 +1848,7 @@ export class AgentHookServer {
private serializeStatusFile(): string {
const entries: Record = {}
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
- // Why: defensive — never persist invalid keys even if they slipped
- // into the in-memory map somehow. Same invariant the hydrate path
- // enforces.
+ // Why: never persist invalid keys (matches the hydrate-path invariant).
if (!isValidPaneKey(paneKey)) {
continue
}
@@ -2037,8 +1863,7 @@ export class AgentHookServer {
if (!this.lastStatusFilePath) {
return
}
- // Why: each call resets the timer; the disk write fires
- // STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst.
+ // Why: reset the timer each call so the write fires only after the last event in a burst.
if (this.statusPersistTimer) {
clearTimeout(this.statusPersistTimer)
}
@@ -2046,9 +1871,7 @@ export class AgentHookServer {
this.statusPersistTimer = null
this.runStatusPersist()
}, STATUS_PERSIST_DEBOUNCE_MS)
- // Why: don't keep the event loop alive just for a status flush — quit
- // already triggers flushStatusPersistSync(). On Node 12+ unref() is a
- // no-op when called on an already-unref'd timer.
+ // Why: don't keep the event loop alive just for a status flush — quit already flushes sync.
if (typeof this.statusPersistTimer.unref === 'function') {
this.statusPersistTimer.unref()
}
@@ -2100,10 +1923,7 @@ export class AgentHookServer {
}
}
- /** Test-only accessor for the per-instance listener state. The `_internals`
- * shim needs to reach this without exposing `state` on the public surface
- * to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
- * so we expose a narrow getter rather than casting the private field. */
+ /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */
_getStateForTests(): HookListenerState {
return this.state
}
@@ -2121,8 +1941,7 @@ export const agentHookServer = new AgentHookServer()
// Why: exported for test coverage of the per-agent field extractors.
export const _internals = {
- // Why: bind the test-helper to the singleton's state so existing tests keep
- // exercising the same caches the live server uses.
+ // Why: bind the test-helper to the singleton's state so tests exercise the live caches.
normalizeHookPayload: (
source: AgentHookSource,
body: unknown,
diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts
index 0bec7bf68fa..0ce3b7f1342 100644
--- a/src/main/browser/agent-browser-bridge.ts
+++ b/src/main/browser/agent-browser-bridge.ts
@@ -50,9 +50,7 @@ import type {
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
-// Why: must exceed agent-browser's internal per-command timeouts (goto defaults to 30s,
-// wait can be up to 60s). Using 90s ensures the bridge never kills a command before
-// agent-browser's own timeout fires and returns a proper error.
+// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires.
const EXEC_TIMEOUT_MS = 90_000
const CONSECUTIVE_TIMEOUT_LIMIT = 3
const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000
@@ -68,8 +66,7 @@ type SessionState = {
// Why: track active interception patterns so they can be re-enabled after session restart
activeInterceptPatterns: string[]
activeCapture: boolean
- // Why: store the webContentsId so we can verify the tab is still alive at execution time,
- // not just at enqueue time. The queue delay can allow the tab to be destroyed in between.
+ // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between.
webContentsId: number
activeProcess: ChildProcess | null
}
@@ -118,8 +115,7 @@ function focusedValueSetExpression(
].join('')
}
-// Why: rich editors reconcile only browser editing transactions; direct DOM
-// fallback can look correct while leaving their model stale.
+// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale.
function focusedRichTextEditExpression(
valueExpression: string,
options?: { selectAll?: boolean }
@@ -166,8 +162,7 @@ type AgentBrowserExecOptions = {
type EnqueueTargetedCommandOptions = {
ensureSession?: boolean
ensureVisible?: boolean
- // Why: text-mutating commands must never fall back to the global active tab,
- // which can point at a different worktree the user is currently viewing.
+ // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing).
requireScopedTarget?: boolean
}
@@ -181,10 +176,7 @@ function agentBrowserNativeName(): string {
}
function resolveAgentBrowserBinary(): string {
- // Why: production builds copy the platform-specific binary into resources/
- // via electron-builder extraResources. Use Electron's resolved resourcesPath
- // instead of hand-rolling ../resources so packaged macOS builds keep working
- // on case-sensitive filesystems where Contents/Resources casing matters.
+ // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary.
const bundledResourcesPath =
process.resourcesPath ??
(process.platform === 'darwin'
@@ -195,9 +187,7 @@ function resolveAgentBrowserBinary(): string {
return bundled
}
- // Why: in dev mode, resolve directly to the native binary inside node_modules.
- // Use app.getAppPath() for a stable project root — __dirname is unreliable after
- // electron-vite bundles main process code into out/main/index.js.
+ // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling.
const nmBin = join(
app.getAppPath(),
'node_modules',
@@ -220,9 +210,7 @@ function resolveAgentBrowserBinary(): string {
return 'agent-browser'
}
-// Why: exec commands arrive as a single string (e.g. 'keyboard inserttext "hello world"').
-// Naive split on whitespace breaks quoted arguments. This parser respects double and
-// single quotes so the value arrives as a single argument without surrounding quotes.
+// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact.
function parseShellArgs(input: string): string[] {
const args: string[] = []
let current = ''
@@ -266,8 +254,7 @@ function stripAgentBrowserTargetArgs(args: string[]): string[] {
return stripped
}
-// Why: agent-browser returns generic error messages for stale/unknown refs.
-// Map them to a specific code so agents can reliably detect and re-snapshot.
+// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot.
function classifyErrorCode(message: string): string {
if (/unknown ref|ref not found|element not found: @e/i.test(message)) {
return 'browser_stale_ref'
@@ -511,29 +498,20 @@ function translateResult(
}
export class AgentBrowserBridge {
- // Why: per-worktree active tab prevents one worktree's tab switch from
- // affecting another worktree's command targeting.
+ // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting.
private readonly activeWebContentsPerWorktree = new Map()
private activeWebContentsId: number | null = null
private readonly sessions = new Map()
private readonly commandQueues = new Map()
private readonly processingQueues = new Set()
- // Why: screenshot prep temporarily changes shared renderer paintability state.
- // Per-session queues only serialize commands within one browser tab, so
- // concurrent screenshots on different tabs can otherwise interleave hidden
- // surface leases and blank each other's capture.
+ // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other.
private screenshotTurn: Promise = Promise.resolve()
private readonly agentBrowserBin: string
- // Why: when a process swap destroys a session that had active intercept patterns,
- // store them here keyed by sessionName so the next ensureSession + first successful
- // command can restore them automatically.
+ // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them.
private readonly pendingInterceptRestore = new Map()
- // Why: two concurrent CLI calls can both enter ensureSession before either creates
- // the session entry. This promise-based lock ensures only one creation proceeds.
+ // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry.
private readonly pendingSessionCreation = new Map>()
- // Why: session destruction shells out to `agent-browser close`, which is async
- // and keyed by session name. Recreating the same session before that close
- // finishes can let the old teardown close the new daemon session.
+ // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session.
private readonly pendingSessionDestruction = new Map>()
private readonly cancelledProcesses = new WeakSet()
@@ -634,14 +612,12 @@ export class AgentBrowserBridge {
newWebContentsId: number,
previousWebContentsId?: number
): Promise {
- // Why: Electron process swaps give same browserPageId but new webContentsId.
- // Old proxy's webContents is destroyed, so destroy session and let next command recreate.
+ // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it.
const sessionName = `orca-tab-${browserPageId}`
const session = this.sessions.get(sessionName)
const oldWebContentsId = previousWebContentsId ?? session?.webContentsId
const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId)
- // Why: save active intercept patterns before destroying so they can be restored
- // on the new session after the next successful init command.
+ // Why: save intercept patterns before destroy so the new session can restore them after init.
if (session && session.activeInterceptPatterns.length > 0) {
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
}
@@ -680,10 +656,7 @@ export class AgentBrowserBridge {
tabList(worktreeId?: string): BrowserTabListResult {
const tabs = this.getRegisteredTabs(worktreeId)
- // Why: use per-worktree active tab for the "active" flag so tab-list is
- // consistent with what resolveActiveTab would pick for command routing.
- // Keep this read-only though: discovery commands must not mutate the
- // active-tab state that later bare commands rely on.
+ // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state.
let activeWcId =
(worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId
const result: BrowserTabInfo[] = []
@@ -703,8 +676,7 @@ export class AgentBrowserBridge {
result.push({
browserPageId: tabId,
index: index++,
- // Why: failed WebContents report chrome-error://, which is neither
- // actionable nor the address the user asked to load.
+ // Why: failed WebContents report chrome-error://, not the address the user asked to load.
url: loadError?.validatedUrl ?? wc.getURL() ?? '',
title: wc.getTitle() ?? '',
active: wcId === activeWcId,
@@ -712,10 +684,7 @@ export class AgentBrowserBridge {
certificateFailure
})
}
- // Why: if no tab has been explicitly activated yet, surface the first live
- // tab as active in the listing without mutating bridge state. That keeps
- // `tab list` side-effect free while still showing users which tab a bare
- // command would select next.
+ // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free.
if (activeWcId == null && firstLiveWcId !== null) {
activeWcId = firstLiveWcId
if (result.length > 0) {
@@ -725,8 +694,7 @@ export class AgentBrowserBridge {
return { tabs: result }
}
- // Why: tab switch must go through the command queue to prevent race conditions
- // with in-flight commands that target the previously active tab.
+ // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab.
async tabSwitch(
index: number | undefined,
worktreeId?: string,
@@ -734,9 +702,7 @@ export class AgentBrowserBridge {
): Promise {
return this.enqueueCommand(worktreeId, async () => {
const tabs = this.getRegisteredTabs(worktreeId)
- // Why: queue delay means the tab list can change between RPC arrival and
- // execution time. Recompute against live webContents here so we never
- // activate a tab index that disappeared while earlier commands were running.
+ // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated.
const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId))
let switchedIndex = index ?? -1
let resolvedPageId = browserPageId
@@ -753,14 +719,9 @@ export class AgentBrowserBridge {
}
const [tabId, wcId] = liveEntries[switchedIndex]
this.activeWebContentsId = wcId
- // Why: resolveActiveTab prefers the per-worktree map over the global when
- // worktreeId is provided. Without this update, subsequent commands would
- // still route to the previous tab despite tabSwitch reporting success.
+ // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab.
const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId)
- // Why: `tab switch --page ` may omit --worktree because the page id is
- // already a stable target. We still need to update the owning worktree's
- // active-tab slot so later worktree-scoped commands follow the tab that was
- // just activated instead of the previously active one.
+ // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands.
if (owningWorktreeId) {
this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId)
}
@@ -817,8 +778,7 @@ export class AgentBrowserBridge {
browserPageId?: string
): Promise {
await assertClipboardTextWriteWithinLimitWithYield(value)
- // Why: agent-browser's CDP text insertion loses focus in Electron guests.
- // Resolve the ref first, then edit through the browser's input pipeline.
+ // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead.
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
@@ -1016,15 +976,11 @@ export class AgentBrowserBridge {
wc.focus()
const point =
cdpButton === 'left'
- ? // Why: DOM activation cannot carry Cmd/Ctrl/Alt/Shift, so modifier
- // clicks use only the adjusted point and let CDP dispatch the event.
+ ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event.
await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0)
: { x, y, adjusted: false, handled: false }
- // Why: mobile taps should land as one atomic input operation. Sending
- // move/down/up through separate CLI calls visibly hovers targets and can
- // miss small controls before the click lands.
- // Runtime may already activate DOM controls because mobile-emulated
- // BrowserViews can ignore CDP mouse clicks for regular page taps.
+ // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls.
+ // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls.
if (!point.handled) {
await wc.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
@@ -1292,10 +1248,7 @@ export class AgentBrowserBridge {
}
async reload(worktreeId?: string, browserPageId?: string): Promise {
- // Why: reload can trigger a process swap in Electron (site-isolation), which
- // destroys the session mid-command. Use the webContents directly for reload
- // instead of going through agent-browser to avoid the session lifecycle issue.
- // Routed through enqueueCommand so it serializes with other in-flight commands.
+ // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead.
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => {
const wc = this.getWebContents(target.webContentsId)
if (!wc) {
@@ -1324,8 +1277,7 @@ export class AgentBrowserBridge {
wc.on('did-finish-load', onFinish)
wc.on('did-fail-load', onFail)
- // Why: successful reloads must clear the fallback timer; otherwise each
- // reload retains the webContents and listeners until the 10s timeout fires.
+ // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout.
fallbackTimer = setTimeout(finish, 10_000)
if (typeof fallbackTimer.unref === 'function') {
fallbackTimer.unref()
@@ -1340,8 +1292,7 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise {
- // Why: agent-browser writes the screenshot to a temp file and returns
- // { "path": "/tmp/screenshot-xxx.png" }. We read the file and return base64.
+ // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64.
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
@@ -1396,10 +1347,7 @@ export class AgentBrowserBridge {
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
- // Why: after acquiring the hidden paintability lease, the compositor
- // needs a short settle period to produce a painted frame. Waiting inside
- // the global screenshot lock prevents another tab from changing lease
- // state before the current capture actually hits CDP.
+ // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first.
await new Promise((r) => setTimeout(r, settleMs))
const raw = await this.execAgentBrowser(sessionName, commandArgs)
return this.readScreenshotFromResult(raw, format)
@@ -1421,9 +1369,7 @@ export class AgentBrowserBridge {
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
- // Why: full-page capture still depends on the guest compositor producing
- // a fresh frame. Wait after the target webview is paintable so the direct
- // CDP capture sees the live page instead of a stale surface.
+ // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface.
await new Promise((r) => setTimeout(r, settleMs))
const wc = this.getWebContents(webContentsId)
if (!wc) {
@@ -1536,11 +1482,7 @@ export class AgentBrowserBridge {
if (normalizedState) {
args.push('--state', normalizedState)
}
- // Why: agent-browser's selector wait surface does not support `--state visible`
- // or a documented per-command `--timeout`. Orca normalizes "visible" back
- // to the default selector wait semantics and enforces the requested timeout
- // at the bridge layer so missing selectors fail as browser_timeout instead
- // of hanging until the generic runtime RPC timeout fires.
+ // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang.
return (await this.execAgentBrowser(sessionName, args, {
timeoutMs:
options?.timeout != null && hasCondition
@@ -1589,8 +1531,7 @@ export class AgentBrowserBridge {
browserPageId,
async (sessionName) => {
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
- // Why: agent-browser resolves this ref directly, preserving iframe,
- // shadow-root, and unfocusable-target semantics for ordinary fields.
+ // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields.
await this.execAgentBrowser(sessionName, ['fill', element, ''])
return { cleared: element }
}
@@ -1628,9 +1569,7 @@ export class AgentBrowserBridge {
}
async pdf(worktreeId?: string, browserPageId?: string): Promise {
- // Why: agent-browser's pdf command via CDP Page.printToPDF hangs in Electron
- // webviews. Use Electron's native webContents.printToPDF() which is reliable.
- // Routed through enqueueCommand so it serializes with other in-flight commands.
+ // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF().
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => {
const wc = this.getWebContents(target.webContentsId)
if (!wc) {
@@ -1727,17 +1666,14 @@ export class AgentBrowserBridge {
throw new BrowserError('browser_error', 'Debugger not attached')
}
- // Why: agent-browser only supports width/height/scale for `set viewport`;
- // it has no `mobile` flag. Orca's CLI exposes `--mobile`, so apply the
- // emulation directly through CDP to keep the public CLI contract honest.
+ // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile.
await dbg.sendCommand('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: scale,
mobile
})
- // Why: BrowserView's compositor surface can keep the previous host size
- // after metrics-only resize, which crops remote screencast clients.
+ // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients.
await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch(
() => {}
)
@@ -1821,9 +1757,7 @@ export class AgentBrowserBridge {
})
}
- // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request
- // interception decisions. Currently agent-browser only operates on URL pattern-level
- // routing, not individual request IDs, so the RPC/CLI interface doesn't map cleanly.
+ // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing.
// ── Capture commands ──
@@ -1890,8 +1824,7 @@ export class AgentBrowserBridge {
async exec(command: string, worktreeId?: string, browserPageId?: string): Promise {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
- // Why: strip target/session flags from raw passthrough commands so a
- // caller cannot override Orca's selected browser page or CDP proxy.
+ // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy.
const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim()))
return await this.execAgentBrowser(sessionName, args)
})
@@ -1968,8 +1901,7 @@ export class AgentBrowserBridge {
return execute(sessionName, target)
}
- // Why: inactive browser panes are display:none in the renderer; the
- // automation lease makes only this target paintable without selecting it.
+ // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it.
const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId)
try {
const visibleTarget = await this.refreshTargetAfterAutomationVisibility(
@@ -2002,10 +1934,7 @@ export class AgentBrowserBridge {
this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId)
}
- // Why: making a parked webview paintable can re-register the same browser
- // page with a new guest webContents. Tear down any stale named session now;
- // DOM commands recreate immediately, direct-CDP commands let the next DOM
- // command recreate against the live guest.
+ // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session.
await this.restartSessionForTarget(
sessionName,
visibleTarget.browserPageId,
@@ -2086,8 +2015,7 @@ export class AgentBrowserBridge {
throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
}
- // Why: prefer per-worktree active tab to prevent cross-worktree interference.
- // Fall back to global activeWebContentsId for callers that don't pass worktreeId.
+ // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId.
const preferredWcId =
(worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId
@@ -2108,10 +2036,7 @@ export class AgentBrowserBridge {
}
}
- // Why: persisted store state can leave ghost tabs whose webContents no longer exist.
- // Skip those and pick the first live tab. Also activate it so tabList and
- // subsequent resolveActiveTab calls are consistent without requiring an
- // explicit tab switch after app startup.
+ // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency.
for (const [tabId, wcId] of tabs) {
if (this.getWebContents(wcId)) {
this.activeWebContentsId = wcId
@@ -2129,14 +2054,7 @@ export class AgentBrowserBridge {
)
}
- // Why: text-mutating commands (inserttext/type/fill) must not silently fall
- // back to the global active tab when no worktree was resolved — that tab can
- // belong to a worktree the user is currently viewing, so a goal-loop agent in
- // another worktree would inject text into the user's foreground webview and
- // steal OS focus. A scoped (worktreeId-bearing) call is already safe because
- // the candidate set is pre-filtered to that worktree, so defer to the lenient
- // resolver. An unscoped call instead requires an unambiguous target: scope to
- // the lone worktree with live tabs, or refuse rather than guess.
+ // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus.
private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget {
if (worktreeId) {
return this.resolveActiveTab(worktreeId)
@@ -2177,9 +2095,7 @@ export class AgentBrowserBridge {
return
}
- // Why: two concurrent CLI calls can both reach here before either finishes
- // creating the session. Without this lock, both would create proxies and the
- // second would overwrite the first, leaking the first proxy's server/debugger.
+ // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger.
const pending = this.pendingSessionCreation.get(sessionName)
if (pending) {
await pending
@@ -2189,19 +2105,14 @@ export class AgentBrowserBridge {
const createSession = async (): Promise => {
const wc = this.getWebContents(webContentsId)
if (!wc) {
- // Why: the renderer can unregister/destroy a webview between target
- // resolution and session creation. Preserve the explicit page identity
- // so callers get the same error shape as a settled closed tab.
+ // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape.
throw new BrowserError(
'browser_tab_not_found',
`Browser page ${browserPageId} is no longer available`
)
}
- // Why: agent-browser's daemon persists session state (including the CDP port)
- // across Orca restarts. A stale session ignores --cdp (already initialized) and
- // connects to the dead port. Must await close so the daemon forgets the session
- // before we pass --cdp with the new port.
+ // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port.
await this.closeStaleAgentBrowserSession(sessionName)
const proxy = new CdpWsProxy(wc)
@@ -2286,13 +2197,11 @@ export class AgentBrowserBridge {
const pendingCreation = this.pendingSessionCreation.get(sessionName)
if (pendingCreation) {
- // Why: tab close can race with stale-session cleanup before sessions.set().
- // Wait for creation to settle so a late proxy cannot survive the close.
+ // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close.
try {
await pendingCreation
} catch {
- // Creation failures are handled by the original caller; teardown still
- // needs to reject queued work and clear any partial state below.
+ // Creation failures are handled by the original caller; teardown still rejects queued work below.
}
}
@@ -2305,14 +2214,11 @@ export class AgentBrowserBridge {
this.sessions.delete(sessionName)
this.pendingSessionCreation.delete(sessionName)
- // Why: queued commands would hang forever if we just delete the queue —
- // their promises would never resolve or reject. Drain and reject them.
+ // Why: queued commands would hang forever if we just delete the queue — drain and reject them.
this.rejectQueuedCommandsForClosedSession(sessionName)
if (session.activeProcess) {
- // Why: queued command rejection is not enough when a daemon command is
- // already running. Kill the active process so callers do not wait for the
- // generic exec timeout after the session/tab has already been destroyed.
+ // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout.
this.cancelledProcesses.add(session.activeProcess)
try {
session.activeProcess.kill()
@@ -2324,9 +2230,7 @@ export class AgentBrowserBridge {
const destroy = (async (): Promise => {
try {
- // Why: each browser tab uses its own named agent-browser session. Closing
- // without --session only tears down the default session and leaves the tab
- // session's daemon process running.
+ // Why: each tab has its own named session — close without --session leaves this tab's daemon running.
await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'])
} catch {
// Session may already be dead
@@ -2365,15 +2269,11 @@ export class AgentBrowserBridge {
): Promise {
const session = this.sessions.get(sessionName)
if (!session) {
- // Why: queued commands can reach execution after a concurrent tab close
- // deletes the session. Surface this as a tab lifecycle error, not an
- // opaque internal bridge failure.
+ // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure.
throw this.createPageUnavailableError(sessionName)
}
- // Why: between enqueue time and execution time (queue delay), the webContents
- // could be destroyed. Check here to give a clear error instead of letting the
- // proxy fail with cryptic Electron debugger errors.
+ // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors.
if (!this.getWebContents(session.webContentsId)) {
await this.destroySession(sessionName)
throw this.createPageUnavailableError(sessionName)
@@ -2383,18 +2283,14 @@ export class AgentBrowserBridge {
const managesInterceptRoutes =
commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute')
- // Why: --cdp is session-initialization only — first command needs it, subsequent don't.
- // Pass as port number (not ws:// URL) so agent-browser hits the proxy's HTTP /json
- // endpoint for target discovery. The proxy only exposes the webview, preventing
- // agent-browser from picking the host renderer page.
+ // Why: --cdp is init-only; pass the port (not a ws:// URL) so agent-browser's /json discovery sees only the proxy's webview, not the host renderer page.
const needsInit = !session.initialized
if (needsInit) {
const port = session.proxy.getPort()
args.push('--cdp', String(port))
}
- // Why: exec passthrough can produce a large argv array; spreading it into
- // push risks V8 argument limits before execFile receives the command.
+ // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits.
for (const commandArg of commandArgs) {
args.push(commandArg)
}
@@ -2412,14 +2308,11 @@ export class AgentBrowserBridge {
)
}
- // Why: only mark initialized after a successful command — if the first --cdp
- // connection fails, the next attempt should retry with --cdp.
+ // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp.
if (needsInit) {
session.initialized = true
- // Why: after a process swap, intercept patterns are lost because the session
- // was destroyed and recreated. Restore them now that the new session is live,
- // unless the caller's first command explicitly reconfigured routing.
+ // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing.
const pendingPatterns = managesInterceptRoutes
? undefined
: this.pendingInterceptRestore.get(sessionName)
@@ -2437,8 +2330,7 @@ export class AgentBrowserBridge {
])
session.activeInterceptPatterns = pendingPatterns
} catch {
- // Why: intercept restore is best-effort — don't fail the user's command
- // if the new page doesn't support the same interception setup.
+ // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it.
}
}
}
@@ -2465,8 +2357,7 @@ export class AgentBrowserBridge {
value: string
): Promise {
await this.execAgentBrowser(sessionName, ['focus', element])
- // Why: stdin avoids argv limits while keeping replacement atomic; chunked
- // editor transactions can move focus and split one fill across controls.
+ // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls.
await this.execAgentBrowser(sessionName, ['eval', '--stdin'], {
stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true })
})
@@ -2490,8 +2381,7 @@ export class AgentBrowserBridge {
resolve()
}
- // Why: this is best-effort daemon cleanup before creating a fresh session;
- // a wedged close command must not block the real browser action.
+ // Why: best-effort daemon cleanup — a wedged close must not block the real browser action.
const timeout = setTimeout(() => {
child?.kill()
finish()
@@ -2516,8 +2406,7 @@ export class AgentBrowserBridge {
fallbackCode: string,
webContentsId?: number
): BrowserError {
- // Why: CDP "connection refused" can also mean a real proxy failure. Only
- // convert it to a closed-page error when bridge state confirms the target is gone.
+ // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone.
if (
fallbackCode === 'browser_error' &&
isTabClosedTransportError(message) &&
@@ -2548,8 +2437,7 @@ export class AgentBrowserBridge {
child = execFile(
this.agentBrowserBin,
args,
- // Why: screenshots return large base64 strings that exceed Node's default
- // 1MB maxBuffer, causing ENOBUFS and a timeout-like failure.
+ // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS).
{
timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
@@ -2592,9 +2480,7 @@ export class AgentBrowserBridge {
}
if (error) {
- // Why: agent-browser exits non-zero for command failures (e.g. clipboard
- // NotAllowedError) but still writes structured JSON to stdout. Parse it
- // so callers get the real error message instead of generic "Command failed".
+ // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error.
if (stdout) {
try {
const parsed = JSON.parse(stdout)
diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts
index 3191509d30c..7bcfd4ea247 100644
--- a/src/main/browser/browser-cookie-import.ts
+++ b/src/main/browser/browser-cookie-import.ts
@@ -1,5 +1,4 @@
-/* eslint-disable max-lines -- Why: cookie import is a single pipeline (detect → decrypt → stage → swap)
- that must stay together so the encryption, schema, and staging steps remain in sync. */
+/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */
import { app, type BrowserWindow, dialog, session } from 'electron'
import { execFileSync } from 'node:child_process'
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
@@ -19,8 +18,7 @@ import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-// Why: writing to userData instead of tmpdir() so the diag log is only
-// readable by the current user, not world-readable in /tmp.
+// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it.
let _diagLog: string | null = null
function getDiagLogPath(): string {
if (!_diagLog) {
@@ -38,8 +36,7 @@ function reasonWithDiagLog(reason: string): string {
const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180
const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512
-// Why: imported cookie errors can include pasted or file-derived payloads;
-// diagnostics only need a short preview, not a full-string whitespace pass.
+// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview.
export function summarizeCookieImportError(err: unknown): string {
const raw = err instanceof Error && err.message ? err.message : String(err)
let summary = ''
@@ -108,8 +105,7 @@ type ChromiumBrowserDef = {
label: string
keychainService: string
keychainAccount: string
- // Why: each platform stores browser data in a different location. The per-platform
- // root paths are resolved at detection time via browserRootPath().
+ // Per-platform data-dir roots, resolved at detection time via browserRootPath().
macRoot?: string
winRoot?: string
linuxRoot?: string
@@ -161,8 +157,7 @@ const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [
},
{
family: 'helium',
- // Why: Helium deviates from the ' Safe Storage'/'' convention —
- // its Keychain entry is literally service 'Helium Storage Key', account 'Helium'.
+ // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'.
label: 'Helium',
keychainService: 'Helium Storage Key',
keychainAccount: 'Helium',
@@ -208,9 +203,7 @@ function isSafeBrowserProfileDirectory(directory: string): boolean {
)
}
-// Why: Chrome's Local State JSON contains profile.info_cache which maps profile
-// directory names (e.g. "Default", "Profile 1") to metadata including the
-// user-visible display name. This lets us show human-readable names in the picker.
+// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker.
function discoverProfiles(browserRoot: string): BrowserProfile[] {
try {
const localStatePath = join(browserRoot, 'Local State')
@@ -267,8 +260,7 @@ function discoverFirefoxProfiles(): BrowserProfile[] {
const entries = readdirSync(profilesRoot, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
- // Why: Firefox profile dirs are named . (e.g. "abc123.default-release").
- // Prefer 'default-release' as it's the primary user profile on most installs.
+ // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs.
const sorted = entries.sort((a, b) => {
if (a.includes('default-release')) {
return -1
@@ -360,8 +352,7 @@ export function detectInstalledBrowsers(): DetectedBrowser[] {
continue
}
const profiles = discoverProfiles(root)
- // Why: a browser is "detected" if at least one profile has a cookies DB.
- // Use the first profile with a valid cookies path as the default selection.
+ // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default.
for (const profile of profiles) {
const profileDir = join(root, profile.directory)
const cookiesPath = resolveChromiumCookiesPath(profileDir)
@@ -393,10 +384,6 @@ export function detectInstalledBrowsers(): DetectedBrowser[] {
return detected
}
-// Why: when the user selects a different profile from the picker, we need to
-// resolve the cookies path for that profile. Returns a new DetectedBrowser
-// with the updated cookiesPath and selectedProfile, or null if the profile
-// has no cookies DB.
export function selectBrowserProfile(
browser: DetectedBrowser,
profileDirectory: string
@@ -463,9 +450,7 @@ type ValidatedCookie = {
expirationDate: number | undefined
}
-// Why: Chromium's SQLite schema uses CookieSameSiteForStorage enum:
-// 0=UNSPECIFIED, 1=NO_RESTRICTION(None), 2=LAX, 3=STRICT.
-// This differs from Firefox (0=None, 1=Lax, 2=Strict).
+// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering.
function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' {
switch (raw) {
case 1:
@@ -512,9 +497,7 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la
return 'unspecified'
}
-// Why: Electron's cookies.set() requires a url field to determine the cookie's
-// scope. Derive it from the domain + secure flag so the caller doesn't need
-// to supply it.
+// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag.
function deriveUrl(domain: string, secure: boolean): string | null {
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
if (!cleanDomain || cleanDomain.includes(' ')) {
@@ -578,8 +561,7 @@ async function importValidatedCookies(
let skipped = totalInput - cookies.length
const domainSet = new Set()
- // Why: Electron's cookies.set() rejects any non-printable-ASCII byte.
- // Strip from all string fields as a safety net.
+ // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
for (const cookie of cookies) {
@@ -596,8 +578,7 @@ async function importValidatedCookies(
expirationDate: cookie.expirationDate
})
importedCount++
- // Why: surface only the domain — never name, value, or path — so the
- // renderer can show a useful summary without leaking secret cookie data.
+ // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
domainSet.add(cleanDomain)
} catch (err) {
@@ -638,8 +619,7 @@ async function importValidatedCookies(
// Import from JSON file
// ---------------------------------------------------------------------------
-// Why: source selection must be main-owned via a native open dialog so a
-// compromised renderer cannot turn cookie import into arbitrary file reads.
+// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads.
export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise {
const opts = {
title: 'Import Cookies',
@@ -714,14 +694,11 @@ export async function importCookiesFromFile(
// Direct import from installed Chromium browser
// ---------------------------------------------------------------------------
-// Why: Google and other services bind auth cookies to the User-Agent that
-// created them. We read the source browser's real version from its plist
-// and construct a matching UA string so imported sessions aren't invalidated.
+// Why: services bind auth cookies to the creating User-Agent, so build a UA matching the source browser's real version.
export function getUserAgentForBrowser(
family: BrowserSessionProfileSource['browserFamily']
): string | null {
- // Why: UA spoofing uses macOS-specific plist reading. On other platforms,
- // skip UA override — the default Electron UA is acceptable.
+ // Why: UA version comes from macOS-only plist reading; elsewhere the default Electron UA is acceptable.
if (process.platform !== 'darwin') {
return null
}
@@ -763,14 +740,12 @@ export function getUserAgentForBrowser(
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
case 'comet': {
- // Why: Comet is Chromium-based and ships a Chrome-shaped version in its plist.
- // Use the same UA shape as Chrome itself so Google-bound auth cookies survive import.
+ // Why: Comet is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import.
const v = readBrowserVersion('/Applications/Comet.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
case 'helium': {
- // Why: Helium is Chromium-based and ships a Chrome-shaped version in its plist.
- // Use the same UA shape as Chrome itself so Google-bound auth cookies survive import.
+ // Why: Helium is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import.
const v = readBrowserVersion('/Applications/Helium.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
@@ -805,16 +780,12 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number {
}
}
-// Why: each platform uses a different mechanism to protect the Chromium cookie encryption key.
-// macOS: PBKDF2(keychain password, "saltysalt", 1003 iterations) → AES-128-CBC
-// Linux: PBKDF2(keyring password or "peanuts", "saltysalt", 1 iteration) → AES-128-CBC
-// Windows: DPAPI-encrypted master key from Local State → AES-256-GCM
+// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM.
type EncryptionKeyResult = {
key: Buffer
mode: 'aes-128-cbc' | 'aes-256-gcm'
- // Why: Linux v10 cookies use a hardcoded "peanuts" password while v11 uses the
- // keyring password. We need both keys to decrypt the full cookie set.
+ // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set.
fallbackKey?: Buffer
}
@@ -926,8 +897,7 @@ export function buildChromiumCookieInsertParams(
return null
}
- // Why: Chromium cookie DB columns drift across Chrome/Electron versions.
- // Missing NOT NULL target columns must get safe Chromium defaults, not NULL.
+ // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL.
return fallbackChromiumCookieColumnValue(column, sourceRow)
})
}
@@ -972,9 +942,7 @@ function getLinuxEncryptionKey(
keychainService: string,
keychainAccount: string
): EncryptionKeyResult | null {
- // Why: Linux v10 cookies use the hardcoded password "peanuts" with 1 PBKDF2
- // iteration. v11 cookies use the actual keyring password. We derive both keys
- // so the decrypt function can try each based on the version prefix.
+ // Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix.
const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1')
let keyringPassword = ''
@@ -1031,8 +999,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult
return null
}
- // Why: PowerShell DPAPI decrypt is the only way to access the master key
- // without native addons. The key is passed via stdin to prevent injection.
+ // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection.
const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64')
const script = [
'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }',
@@ -1056,12 +1023,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult
}
}
-// Why: Chromium 127+ prepends a 32-byte per-host HMAC to the cookie value
-// before encrypting. After AES-CBC decryption, the raw output is:
-// [32-byte HMAC] [actual cookie value]
-// Detection: the HMAC is a hash, so roughly half its bytes are non-printable
-// ASCII. Real cookie values are overwhelmingly printable. If ≥8 of the first
-// 32 bytes are non-printable, it's an HMAC prefix.
+// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix.
const CHROMIUM_COOKIE_HMAC_LEN = 32
function hasHmacPrefix(buf: Buffer): boolean {
@@ -1103,8 +1065,7 @@ function decryptCookieValueRaw(
return Buffer.alloc(0)
}
- // Why: Linux v10 uses "peanuts" key, v11 uses keyring key. Try the primary
- // key first, then fallback. macOS uses the same key for both versions.
+ // Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key).
const keysToTry =
version === 'v10' && keyResult.fallbackKey
? [keyResult.fallbackKey, keyResult.key]
@@ -1175,8 +1136,7 @@ function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] {
}
function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void {
- // Why: Safari binary cookie pages can contain generated-size cookie lists;
- // spreading a decoded page into push can exceed JavaScript's argument limit.
+ // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit.
for (const cookie of cookies) {
target.push(cookie)
}
@@ -1215,8 +1175,7 @@ function decodeSafariCookie(buf: Buffer): ValidatedCookie | null {
if (buf.length < 48) {
return null
}
- // Why: size is read from the binary file and could be attacker-controlled.
- // Clamp to buf.length so readCString cannot escape the cookie's subarray.
+ // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray.
const size = Math.min(buf.readUInt32LE(0), buf.length)
if (size < 48) {
return null
@@ -1402,8 +1361,7 @@ async function importCookiesFromSafari(
data = readFileSync(browser.cookiesPath)
} catch (err) {
diag(` Safari read failed: ${err}`)
- // Why: Safari's Cookies.binarycookies lives inside a macOS sandbox container.
- // Reading it requires Full Disk Access in System Settings → Privacy & Security.
+ // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access.
const isPermError =
err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM'
if (isPermError) {
@@ -1459,19 +1417,9 @@ export async function importCookiesFromBrowser(
return importCookiesFromSafari(browser, targetPartition)
}
- // Why: Electron's cookies.set() API rejects many valid cookie values (binary
- // bytes > 0x7F etc). Instead, decrypt from the source browser and write
- // plaintext directly to the SQLite `value` column. CookieMonster reads
- // `value` as a raw byte string when `encrypted_value` is empty, bypassing
- // all API-level validation. This works because Electron's CookieMonster in
- // dev mode does not use os_crypt encryption — it stores cookies as plaintext.
- // In packaged builds where os_crypt IS active, CookieMonster will re-encrypt
- // plaintext cookies on its next flush, so this approach is safe in both modes.
+ // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds.
- // Why: CookieMonster holds the live DB's data in memory and overwrites it
- // on flush/shutdown. Writing directly to the live DB is futile. Instead,
- // copy the live DB to a staging location, populate it there, and let the
- // next cold start swap it in before CookieMonster initializes.
+ // Why: CookieMonster overwrites the live DB on flush, so stage a populated copy and swap it in at next cold start.
const targetSession = session.fromPartition(targetPartition)
await targetSession.cookies.flushStore()
@@ -1479,10 +1427,7 @@ export async function importCookiesFromBrowser(
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
let liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
- // Why: Electron only creates the partition's Cookies SQLite file after the
- // session has actually stored a cookie. For newly created profiles that have
- // never been used by a webview, the file won't exist yet. Setting and
- // removing a throwaway cookie forces Electron to initialize the database.
+ // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles.
if (!liveCookiesPath) {
try {
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
@@ -1508,8 +1453,7 @@ export async function importCookiesFromBrowser(
mkdirSync(stagingDir, { recursive: true })
copyFileSync(liveCookiesPath, stagingCookiesPath)
} catch {
- // Why: copyFile is not atomic and can leave a partial database after an
- // I/O failure, so failed imports must not retain sensitive cookie data.
+ // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data.
try {
unlinkSync(stagingCookiesPath)
} catch {
@@ -1520,8 +1464,7 @@ export async function importCookiesFromBrowser(
let sourceSnapshot: ChromiumCookieSnapshot
try {
- // Why: the browser can commit cookies only to WAL while it remains open;
- // snapshot retries prevent pairing the main DB with a racing WAL generation.
+ // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL.
sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath)
} catch (err) {
try {
@@ -1540,8 +1483,7 @@ export async function importCookiesFromBrowser(
let stagingDb: InstanceType | null = null
try {
- // Why: Chromium stores timestamps as microseconds since 1601, which can exceed
- // Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss.
+ // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss.
sourceDb = new DatabaseSync(sourceSnapshot.databasePath, {
readOnly: true,
readBigInts: true
@@ -1586,8 +1528,7 @@ export async function importCookiesFromBrowser(
if (needsSourceKey && !sourceKey) {
stagingDb.close()
stagingDb = null
- // Why: key denial happens after staging, so failed retries must not leave
- // one full target database copy behind each time.
+ // Why: key denial happens after staging, so clean up the target DB copy or retries pile up.
try {
unlinkSync(stagingCookiesPath)
} catch {
@@ -1599,11 +1540,7 @@ export async function importCookiesFromBrowser(
}
}
- // Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP)
- // are cryptographically bound to the source browser's TLS fingerprint and
- // environment. Importing them into a different browser causes
- // accounts.google.com to reject the session with CookieMismatch. Skipping
- // them lets Google regenerate fresh integrity cookies on the first request.
+ // Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue.
const INTEGRITY_COOKIE_NAMES = new Set([
'SIDCC',
'__Secure-1PSIDCC',
@@ -1649,9 +1586,7 @@ export async function importCookiesFromBrowser(
for (const sourceRow of sourceRows) {
const encRaw = sourceRow.encrypted_value
- // Why: node:sqlite returns BLOB columns as Uint8Array. Any other truthy type
- // means the schema is unexpected — treat it as missing rather than creating
- // an empty buffer that would silently produce a blank cookie value.
+ // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value.
const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null
const plainRaw = sourceRow.value
@@ -1687,9 +1622,7 @@ export async function importCookiesFromBrowser(
const httpOnly = sourceRow.is_httponly === 1n
const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0))
const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint)
- // Why: cookie values are raw byte strings, not UTF-8 text. Using latin1
- // (ISO-8859-1) preserves all byte values 0x00–0xFF without replacement
- // characters that UTF-8 decoding would insert for invalid sequences.
+ // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement.
const value = decryptedValue.toString('latin1')
decryptedCookies.push({
@@ -1716,18 +1649,13 @@ export async function importCookiesFromBrowser(
diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`)
- // Why: clearing the session's in-memory cookie store before loading imported
- // cookies prevents stale cookies from a previous Orca browsing session from
- // mixing with the imported set. Mixed state (some old, some imported) causes
- // sites like Google to detect inconsistent session cookies and reject them.
+ // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session.
await targetSession.clearStorageData({ storages: ['cookies'] })
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
)
- // Why: loading cookies into memory via cookies.set() makes them available
- // immediately without requiring a restart. The staging DB is kept as a
- // fallback for any cookies that fail the cookies.set() validation.
+ // Why: load into memory via cookies.set() so imported cookies work without a restart.
for (const cookie of decryptedCookies) {
const url = deriveUrl(cookie.domain, cookie.secure)
if (!url) {
@@ -1735,8 +1663,7 @@ export async function importCookiesFromBrowser(
continue
}
try {
- // Why: __Host- prefixed cookies must not have a domain attribute and
- // must have path=/. Chromium rejects them otherwise.
+ // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
const isHostPrefixed = cookie.name.startsWith('__Host-')
await targetSession.cookies.set({
url,
@@ -1758,9 +1685,7 @@ export async function importCookiesFromBrowser(
diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`)
if (memoryFailed > 0) {
- // Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values
- // or other validation failures). Keep the staging DB so the next cold start
- // picks them up from SQLite where CookieMonster reads them without validation.
+ // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation.
browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath)
diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`)
} else {
@@ -1799,8 +1724,7 @@ export async function importCookiesFromBrowser(
} catch {
/* may already be closed */
}
- // Why: if the import fails after the staging DB was created, clean it up
- // to avoid a stale staged import being applied on the next cold start.
+ // Why: drop the staging DB so a stale staged import isn't applied on the next cold start.
try {
unlinkSync(stagingCookiesPath)
} catch {
diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts
index 1185e48d48e..997b81f4fe6 100644
--- a/src/main/browser/browser-guest-ui.ts
+++ b/src/main/browser/browser-guest-ui.ts
@@ -1,7 +1,4 @@
-/* eslint-disable max-lines -- Why: browser guest UI policy is the single
-privileged bridge for context menus, grab-mode shortcuts, and app-shortcut
-forwarding from webContents guests. Splitting this rebase-only integration
-would make the security boundary harder to audit. */
+/* eslint-disable max-lines -- Why: single privileged bridge for guest context menus, grab-mode, and app-shortcut forwarding; splitting would blur the security boundary. */
import { screen, webContents } from 'electron'
import {
normalizeBrowserNavigationUrl,
@@ -101,23 +98,15 @@ export function setupGuestContextMenu(args: {
if (!renderer) {
return
}
- // 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.
+ // Why: redact the Kagi session token before pageUrl leaves main — the renderer pipes it into clipboard and shell.openExternal.
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
- // renderer into showing "Open Link…" items for every right-click.
+ // Why: empty linkURL normalized would yield the truthy blank-page constant, showing "Open Link…" on every non-link right-click.
const rawLinkUrl = params.linkURL || ''
const linkUrl =
rawLinkUrl.length > 0
? (normalizeExternalBrowserUrl(rawLinkUrl) ?? normalizeBrowserNavigationUrl(rawLinkUrl))
: null
- // Why: send BOTH the guest viewport coordinates AND the OS screen cursor
- // position. The renderer will try the screen cursor approach (which is
- // immune to guest/renderer coordinate space mismatches) and fall back to
- // guest coords if the screen API is unavailable.
+ // Why: send both viewport and screen-cursor coords; screen cursor avoids coordinate-space mismatch, guest coords are the fallback.
const cursor = screen.getCursorScreenPoint()
const navigationState = readGuestNavigationState(guest)
renderer.send('browser:context-menu-requested', {
@@ -128,18 +117,13 @@ export function setupGuestContextMenu(args: {
screenY: cursor.y,
pageUrl,
linkUrl,
- // Why: forward the native selection so the renderer can offer a Copy that
- // writes it to the clipboard directly, bypassing pages that suppress copy
- // via oncopy handlers (the reported bug — the selection is never re-read
- // through a page-visible copy event).
+ // Why: forward the native selection so the renderer can Copy it directly, bypassing pages that suppress copy via oncopy handlers.
selectionText: params.selectionText ?? '',
...navigationState
})
}
- // Why: `before-mouse-event` fires for every mouse event (move, down, up,
- // scroll) on the guest. Installing the dismiss listener only while a context
- // menu is open avoids an IPC dispatch per mouse event on idle guests.
+ // Why: before-mouse-event fires on every move/scroll; install the dismiss listener only while a menu is open to avoid per-event IPC.
let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null =
null
@@ -162,10 +146,7 @@ export function setupGuestContextMenu(args: {
if (mouse.type !== 'mouseDown') {
return
}
- // Why: a right-click mouseDown will be followed by a new context-menu
- // event with updated coordinates. Sending a dismiss here would cause
- // the renderer to briefly close the menu (trigger snaps to 0,0) then
- // reopen it, producing a visible flash at the top-left corner.
+ // Why: a right-click mouseDown precedes a new context-menu event; dismissing here flashes the menu closed then reopens it at 0,0.
if (mouse.button === 'right') {
return
}
@@ -185,20 +166,12 @@ export function setupGuestContextMenu(args: {
guest.off('context-menu', contextMenuHandler)
removeDismissListener()
} catch {
- // Why: browser tabs can outlive the guest webContents briefly during
- // teardown. Cleanup should be best-effort instead of throwing while the
- // IDE is closing a tab.
+ // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort.
}
}
}
-// Why: browser grab mode intentionally uses Cmd/Ctrl+C as its entry
-// gesture, but a focused webview guest is a separate Chromium process so
-// the renderer's window-level keydown handler never sees that shortcut.
-// Only forward the chord when Chromium would not perform a normal copy:
-// no editable element is focused and there is no selected text. That keeps
-// native page copy working while still making the grab shortcut reachable
-// from focused web content.
+// Why: a focused guest never surfaces Cmd/Ctrl+C to the renderer; forward only when it wouldn't do a normal copy (no editable focus, no selection).
export function setupGrabShortcutForwarding(args: {
browserTabId: string
guest: Electron.WebContents
@@ -224,9 +197,7 @@ export function setupGrabShortcutForwarding(args: {
if (!renderer) {
return
}
- // Why: a focused guest swallows bare keys before the renderer sees them.
- // While grab mode is actively awaiting a pick, plain C/S belong to Orca's
- // copy/screenshot shortcuts rather than the page's typing behavior.
+ // Why: a focused guest swallows bare keys; during an active grab pick, plain C/S are Orca's copy/screenshot, not page typing.
event.preventDefault()
renderer.send('browser:grabActionShortcut', { browserPageId: browserTabId, key: bareKey })
return
@@ -268,8 +239,7 @@ export function setupGrabShortcutForwarding(args: {
renderer.send('browser:grabModeToggle', browserTabId)
})
.catch(() => {
- // Why: shortcut forwarding is best-effort. Guest teardown or a
- // transient executeJavaScript failure should not break normal copy.
+ // Why: shortcut forwarding is best-effort — guest teardown or a transient executeJavaScript failure must not break normal copy.
})
}
@@ -278,17 +248,12 @@ export function setupGrabShortcutForwarding(args: {
try {
guest.off('before-input-event', handler)
} catch {
- // Why: browser tabs can outlive the guest webContents briefly during
- // teardown. Cleanup should be best-effort.
+ // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort.
}
}
}
-// Why: a focused webview guest is a separate Chromium process — keyboard
-// events go to the guest's own webContents and never fire the renderer's
-// window-level keydown handler or the main window's before-input-event.
-// Intercept common app shortcuts on the guest and forward them to the
-// renderer so they work consistently regardless of which surface has focus.
+// Why: a focused webview guest is its own Chromium process whose key events never reach the renderer; forward shortcuts from here.
export function setupGuestShortcutForwarding(args: {
browserTabId: string
guest: Electron.WebContents
@@ -326,8 +291,7 @@ export function setupGuestShortcutForwarding(args: {
): boolean => {
const keybindings = getKeybindings?.()
if (action?.type === 'zoom') {
- // Why: focused browser guests own page zoom, but their key events never
- // reach the renderer-owned webview ref that can apply Orca's page zoom.
+ // Why: focused guest key events never reach the renderer-owned webview ref that applies Orca's page zoom.
forwardBrowserPageZoom(event, action.direction)
return true
}
@@ -339,12 +303,7 @@ export function setupGuestShortcutForwarding(args: {
return false
}
if (action?.type === 'worktreeHistoryNavigate') {
- // Why: preventDefault unconditionally — if we cannot resolve the
- // renderer (torn-down tab or teardown race), dropping the keystroke
- // into the guest's webContents would let Chromium / the guest page
- // handle Cmd+Alt+Arrow as their own chord (e.g. guest-side text
- // navigation). Consistency with the main-window path is preserved
- // only by suppressing the event here too.
+ // Why: preventDefault unconditionally so the guest never handles Cmd+Alt+Arrow itself, even when the renderer can't be resolved.
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:worktreeHistoryNavigate', action.direction)
@@ -358,9 +317,7 @@ export function setupGuestShortcutForwarding(args: {
return true
}
- // Why: match this action outside the main-process shortcut allowlist so
- // both the fresh Shift binding and upgrading users' seeded Alt binding
- // reach the renderer instead of the focused guest page.
+ // Why: match outside the allowlist so both the new Shift binding and upgraders' seeded Alt binding reach the renderer.
const switchAllTypesDirection = keybindingMatchesAction(
'tab.nextAllTypes',
input,
@@ -385,8 +342,7 @@ export function setupGuestShortcutForwarding(args: {
return true
}
- // Why: terminal-only tab switching defaults to Ctrl+PageUp/PageDown on every
- // platform, but still goes through the registry so disable/rebind is real.
+ // Why: terminal-tab switching defaults to Ctrl+PageUp/PageDown but goes through the registry so disable/rebind still works.
const terminalTabDirection = keybindingMatchesAction(
'tab.nextTerminal',
input,
@@ -419,45 +375,29 @@ export function setupGuestShortcutForwarding(args: {
} else if (keybindingMatchesAction('tab.newMarkdown', input, process.platform, keybindings)) {
renderer.send('ui:newMarkdownTab')
} else if (keybindingMatchesAction('tab.newTerminal', input, process.platform, keybindings)) {
- // Why: Cmd/Ctrl+T opens a terminal in the user's active terminal surface
- // even when focus is inside a browser guest. Cmd/Ctrl+Shift+B is the
- // dedicated shortcut for new browser tabs.
+ // Why: Cmd/Ctrl+T opens a terminal even when a browser guest is focused (Shift+B is the new-browser-tab shortcut).
renderer.send('ui:newTerminalTab')
} else if (
keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings)
) {
- // 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
- // can focus its own input just like a standalone browser would.
+ // Why: the address bar lives in renderer chrome, not the guest page; forward so the active BrowserPane can focus its input.
renderer.send('ui:focusBrowserAddressBar')
} else if (
keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings)
) {
- // Why: Cmd/Ctrl+Shift+R is the browser convention for hard reload
- // (bypass cache). The guest would handle it natively, but Orca's webview
- // reloadIgnoringCache() call must come from the renderer side so it goes
- // through the same parked-webview ref that owns the guest surface.
+ // Why: forward hard reload so reloadIgnoringCache() runs on the renderer's parked-webview ref that owns the guest surface.
renderer.send('ui:hardReloadBrowserPage')
} else if (keybindingMatchesAction('browser.reload', input, process.platform, keybindings)) {
- // Why: same as above for soft reload — Cmd/Ctrl+R must be forwarded so
- // the renderer can call reload() on its own webview ref rather than
- // relying on the guest's built-in shortcut, which may not reach the
- // parked-webview eviction logic.
+ // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips.
renderer.send('ui:reloadBrowserPage')
} else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) {
- // Why: Cmd/Ctrl+F must be forwarded out of the guest so the renderer can
- // open its own find-in-page bar and call webview.findInPage(). Letting the
- // guest handle it natively would open Chromium's built-in find UI inside
- // the guest frame, which is invisible behind Orca's chrome.
+ // Why: guest-native find UI is invisible behind Orca's chrome; forward so the renderer opens its own find-in-page bar.
renderer.send('ui:findInBrowserPage')
} else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) {
- // Why: macOS Logitech side-button remaps arrive as browser history
- // keystrokes, not mouse/app-command events. Forward out of the guest so
- // the renderer-owned webview ref can call goBack().
+ // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack().
renderer.send('ui:browserHistoryNavigate', 'back')
} else if (keybindingMatchesAction('browser.forward', input, process.platform, keybindings)) {
- // Why: same as browser.back; the focused guest cannot call the
- // renderer-owned parked webview's goForward() path directly.
+ // Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly.
renderer.send('ui:browserHistoryNavigate', 'forward')
} else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) {
renderer.send('ui:closeActiveTab')
@@ -495,8 +435,7 @@ export function setupGuestShortcutForwarding(args: {
} else {
return false
}
- // Why: preventDefault stops the guest page from also processing the chord
- // (e.g. Cmd+T opening a browser-internal new-tab page).
+ // Why: preventDefault stops the guest page from also processing the chord (e.g. Cmd+T opening a browser-internal new-tab page).
event.preventDefault()
return true
}
@@ -552,11 +491,7 @@ export function setupGuestShortcutForwarding(args: {
if (input.type !== 'keyDown') {
return
}
- // Why: resolve the policy action once per keystroke. The history-navigate
- // chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries
- // Alt and must be handled before the generic modifier-chord gate below,
- // which rejects Alt. Every other chord handled further down can reuse
- // the same `action` rather than re-running the full predicate chain.
+ // Why: Cmd/Ctrl+Alt+Arrow is the only allowlisted chord carrying Alt, so resolve it before the Alt-rejecting chord gate below.
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
forwardShortcutInput(event, input, action)
}
@@ -568,8 +503,7 @@ export function setupGuestShortcutForwarding(args: {
if (zoomDirection !== 'in' && zoomDirection !== 'out') {
return
}
- // Why: some keyboard layouts/platforms turn Ctrl/Cmd +/- into Electron's
- // native zoom command before `before-input-event` reaches the guest.
+ // Why: some layouts/platforms turn Ctrl/Cmd +/- into Electron's native zoom before before-input-event reaches the guest.
if (consumeRecentGuestWheelZoom(guest, zoomDirection)) {
event.preventDefault()
return
@@ -605,8 +539,7 @@ export function setupGuestMouseWheelZoomForwarding(args: {
if (!direction) {
return
}
- // Why: wheel input over a focused webview does not reach renderer DOM
- // handlers, so consume it here and forward to the existing page-zoom path.
+ // Why: wheel input over a focused webview never reaches renderer DOM handlers, so consume and forward here.
event.preventDefault()
markGuestWheelZoom(guest, direction)
resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction)
diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts
index 33104ad9f7d..bd0e5698cd8 100644
--- a/src/main/browser/browser-manager.ts
+++ b/src/main/browser/browser-manager.ts
@@ -1,7 +1,4 @@
-/* eslint-disable max-lines -- Why: BrowserManager intentionally remains the
-single privileged facade for guest registration, authorization, and lifecycle
-cleanup even after extracting the grab/session helpers. Keeping that ownership
-in one file avoids scattering the browser security boundary across modules. */
+/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */
import { randomUUID } from 'node:crypto'
import { shell, webContents } from 'electron'
@@ -108,9 +105,7 @@ function cleanupLateAutomationVisibilityToken(
if (typeof lateToken !== 'string' || lateToken.length === 0) {
return
}
- // Why: the renderer creates the lease before waiting for paint; if main's
- // acquire timeout wins, release the eventual token so hidden webviews do
- // not stay paintable indefinitely.
+ // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable.
releaseAutomationVisibilityToken(renderer, lateToken)
})
.catch(() => {})
@@ -131,10 +126,7 @@ function isAutomationVisibilityToken(token: unknown): token is string {
return typeof token === 'string' && token.length > 0
}
-// Why: mobile presets need a touch-capable UA or responsive sites serve the
-// desktop variant based on UA sniffing. This is the Chrome DevTools default
-// iPhone UA template; we splice in the guest session's real Chrome major so
-// sec-ch-ua headers (see setupClientHintsOverride) stay consistent.
+// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
function buildMobileUserAgent(chromeMajor: string): string {
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
}
@@ -176,9 +168,7 @@ const SAFE_POPUP_WINDOW_OPTIONS = {
skipTaskbar: false,
titleBarStyle: 'default',
transparent: false,
- // Why: applied by Electron when it creates the popup's WebContents, before
- // createWindow runs. Feature strings and opener inheritance must not be able
- // to relax the child's process isolation.
+ // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation.
webPreferences: {
allowRunningInsecureContent: false,
contextIsolation: true,
@@ -226,20 +216,14 @@ export class BrowserManager {
})
| null = null
private readonly webContentsIdByTabId = new Map()
- // Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear
- // scans on every mouse event, load failure, permission, and popup event.
+ // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event.
private readonly tabIdByWebContentsId = new Map()
private readonly popupOwnerContextByGuestId = new Map()
- // Why: guest registration is keyed by browser page id, but renderer
- // visibility/focus state is keyed by browser workspace id. Screenshot prep
- // has to bridge that mismatch to activate the right tab before capture.
+ // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture.
private readonly workspaceIdByPageId = new Map()
private readonly sessionProfileIdByPageId = new Map()
private readonly rendererWebContentsIdByTabId = new Map()
- // Why: chain setViewportOverride calls per tab so rapid toggles don't
- // interleave CDP commands. Without serialization, two concurrent calls can
- // race (e.g. clearDeviceMetricsOverride landing after a later mobile
- // setDeviceMetricsOverride), leaving emulation in an unexpected state.
+ // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state.
private readonly viewportOpsByTabId = new Map>()
private readonly contextMenuCleanupByTabId = new Map void>()
private readonly grabShortcutCleanupByTabId = new Map void>()
@@ -252,9 +236,7 @@ export class BrowserManager {
private readonly policyCleanupByGuestId = new Map void>()
private readonly clickedLinkFrameNameByGuestId = new Map()
private readonly loadErrorsByGuestId = new Map()
- // Why: did-start-navigation optimistically hides the overlay, but an aborted
- // nav never commits — stash the cleared error so did-fail-load(-3) can restore
- // it instead of stranding the user on a blank surface.
+ // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav.
private readonly clearedLoadErrorsByGuestId = new Map()
private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null
private certificateTrustController: BrowserCertificateTrustController | null = null
@@ -298,14 +280,7 @@ export class BrowserManager {
this.settingsResolver = resolver
}
- // Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the
- // only reliable way to run JS before page scripts on every navigation.
- // The previous approach — executeJavaScript on did-start-navigation — ran
- // on the OLD page context during navigation, so overrides were never
- // present when the new page's Turnstile script executed.
- //
- // Returns a cleanup function that removes the detach listener and prevents
- // further re-attach attempts.
+ // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context.
private injectAntiDetection(guest: Electron.WebContents): () => void {
let disposed = false
let reattachTimer: ReturnType | null = null
@@ -331,11 +306,7 @@ export class BrowserManager {
}
}
- // Why: the CDP proxy and bridge detach the debugger when they stop,
- // which removes addScriptToEvaluateOnNewDocument injections. Re-attach
- // so manual browsing retains anti-detection overrides after agent
- // sessions end. The 500ms delay avoids racing with the proxy/bridge if
- // it is mid-restart (detach → re-attach).
+ // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides.
const onDetach = (): void => {
if (!disposed && !guest.isDestroyed() && reattachTimer === null) {
reattachTimer = setTimeout(() => {
@@ -398,10 +369,7 @@ export class BrowserManager {
return renderer
}
- // Why: screenshot sessions target guest page ids, but Orca's visible browser
- // chrome is keyed by workspace ids. If we activate the page id directly, the
- // webview stays hidden under the terminal pane and Page.captureScreenshot
- // times out even though the guest still exists.
+ // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out.
async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> {
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
if (!browserPageId) {
@@ -622,8 +590,7 @@ export class BrowserManager {
return () => {}
}
- // Why: agent browser commands need a paintable webview for lazy-loading
- // sites, but must not steal the user's visible Orca tab/worktree.
+ // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab.
const acquirePromise = renderer
.executeJavaScript(
`(async function() {
@@ -659,8 +626,7 @@ export class BrowserManager {
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
- // Why: OAuth child windows must retain normal link/window relationships;
- // only the primary embedded browser converts new-tab clicks to Orca tabs.
+ // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior.
const clickedLinkFrameName = inheritedOwnerContext
? null
: `__orca_clicked_link_foreground_${randomUUID()}`
@@ -669,30 +635,22 @@ export class BrowserManager {
}
let clickedLinkRoutingActive = Boolean(clickedLinkFrameName)
- // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs
- // (navigator.webdriver, plugins, window.chrome) that differ in Electron
- // webviews vs real Chrome. Inject overrides on every page load so manual
- // browsing passes challenges even without the CDP debugger attached.
+ // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes.
const disposeAntiDetection = this.injectAntiDetection(guest)
- // Why: background throttling must be disabled so agent-driven screenshots
- // (Page.captureScreenshot via CDP proxy) can capture frames even when the
- // Orca window is not the focused foreground app. With throttling enabled,
- // the compositor stops producing frames and capturePage() returns empty.
+ // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
guest.setBackgroundThrottling(false)
const installClickedLinkRouting = (): void => {
if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) {
return
}
- // Why: an isolated-world click listener can label real anchor clicks
- // without exposing the private frame name to untrusted page scripts.
+ // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts.
void guest
.executeJavaScriptInIsolatedWorld(
BROWSER_CLICKED_LINK_ROUTING_WORLD_ID,
[
{
- // Why: mobile emulation spoofs the guest UA as iOS, so modifier
- // routing must use the actual desktop host platform from main.
+ // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing.
code: buildBrowserClickedLinkRoutingScript(
clickedLinkFrameName,
process.platform === 'darwin'
@@ -725,8 +683,7 @@ export class BrowserManager {
const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}`
iframeFrameNameByFrame.set(frame, name)
iframeFrameByFrameName.set(name, frame)
- // Why: child-frame tokens live in the page world, so they are consumed
- // after one trusted click and replaced before another can be routed.
+ // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next.
void frame
.executeJavaScript(
buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'),
@@ -761,8 +718,7 @@ export class BrowserManager {
guest.on('frame-created', handleFrameCreated)
}
const handleDidCreateWindow = (window: Electron.BrowserWindow): void => {
- // Why: popup descendants inherit the opener's owner context for routing,
- // but must not replace its primary guest registration.
+ // Why: popup descendants inherit the opener's owner context but must not replace its primary registration.
this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id))
}
guest.on('did-create-window', handleDidCreateWindow)
@@ -788,39 +744,30 @@ export class BrowserManager {
action: 'opened-in-orca'
})
}
- // Why: a recognized user gesture must never fall through to a native
- // popup merely because its renderer disappeared during the click.
+ // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click.
return { action: 'deny' }
}
- // Why: file URLs are valid for user-opened in-pane previews, but remote
- // content must not create native child windows targeting local paths.
+ // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths.
const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL)
if (browserTabId && canOpenAsChild) {
- // Why: OAuth may request ordinary size/position features, but browser
- // content must not create deceptive or inescapable native chrome.
+ // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome.
return {
action: 'allow',
overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS,
- // Why: a default child window has no address bar, so users cannot
- // verify a popup's destination. Host it in an Orca window with an
- // origin bar while keeping the shared session + window.opener.
+ // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable.
createWindow: (options: PopupChildWindowOptions) =>
this.createPopupChildWindowWithOriginBar(guest, url, options)
}
} else if (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.
+ // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser.
void shell.openExternal(redactKagiSessionToken(externalUrl))
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(externalUrl),
action: 'opened-external'
})
} else {
- // Why: popup attempts can carry auth redirects and one-time tokens.
- // Surface only sanitized origin metadata so the renderer can explain
- // the blocked action without persisting sensitive URL details.
+ // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata.
this.forwardOrQueuePopupEvent(guest.id, {
origin: safeOrigin(url),
action: 'blocked'
@@ -830,26 +777,17 @@ export class BrowserManager {
})
const navigationGuard = (event: Electron.Event, url: string): void => {
- // Why: blob: URLs are same-origin (inherit the creator's origin) and are
- // used by Cloudflare Turnstile to load challenge resources inside iframes.
- // Blocking them triggers error 600010 ("bot behavior detected"). Only
- // allow blobs whose embedded origin is http(s) to maintain defense-in-depth
- // against blob:null or other opaque-origin blobs.
+ // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones.
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
return
}
- // Why: file:// is permitted at `will-attach-webview` so the preview pane
- // can render local HTML the user explicitly opened. After that initial
- // load, a page must not be able to redirect the guest to file:// — that
- // would let a remote page probe the local filesystem. Keep the in-guest
- // navigation guard strict even though initial attach is permissive.
+ // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS.
if (url.startsWith('file:')) {
event.preventDefault()
return
}
if (!normalizeBrowserNavigationUrl(url)) {
- // Why: `will-attach-webview` only validates the initial src. Main must
- // keep enforcing the same allowlist for later guest navigations too.
+ // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs.
event.preventDefault()
}
}
@@ -873,14 +811,11 @@ export class BrowserManager {
toSecureCertificateEndpoint(validatedURL || guest.getURL()) ===
toSecureCertificateEndpoint(certificateFailure.origin)
) {
- // Why: a request-guard cancellation is the transport for the existing
- // certificate warning; do not replace it with ERR_ABORTED/blocked copy.
+ // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy.
return
}
if (errorCode === -3) {
- // Why: an aborted main-frame nav never committed, so restore the error
- // did-start-navigation optimistically cleared — otherwise a retry that
- // aborts leaves the failed page with no overlay.
+ // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost.
const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id)
if (clearedError !== undefined) {
this.clearedLoadErrorsByGuestId.delete(guest.id)
@@ -911,14 +846,11 @@ export class BrowserManager {
return
}
this.certificateTrustController?.onMainFrameNavigationStarted(guest.id)
- // Why: a failure queued before renderer registration belongs only to the
- // navigation that produced it. A replacement navigation must not replay
- // that stale failure when its later dom-ready registers the guest.
+ // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it.
this.pendingLoadFailuresByGuestId.delete(guest.id)
const activeError = this.loadErrorsByGuestId.get(guest.id)
if (activeError === undefined) {
- // Why: no error to hide; drop any stale stash so a later abort cannot
- // resurrect an error from a navigation that already succeeded.
+ // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure.
this.clearedLoadErrorsByGuestId.delete(guest.id)
return
}
@@ -928,9 +860,7 @@ export class BrowserManager {
}
const didNavigateHandler = (_event: Electron.Event, url: string): void => {
- // Why: a committed navigation means the optimistic stash from
- // did-start-navigation is obsolete — drop it so a later ERR_ABORTED
- // cannot restore a failure over the already-committed page.
+ // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it.
this.clearedLoadErrorsByGuestId.delete(guest.id)
this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url)
}
@@ -941,15 +871,12 @@ export class BrowserManager {
guest.on('did-navigate', didNavigateHandler)
guest.on('did-fail-load', didFailLoadHandler)
const handleDestroyed = (): void => {
- // Why: guests can be destroyed before renderer registration. Without
- // this, attach-time policy closures remain retained until app shutdown.
+ // Why: guests can die before renderer registration, else attach-time closures leak until shutdown.
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
- // Why: store cleanup so unregisterGuest can remove these listeners when the
- // guest surface is torn down, preventing the callbacks from preventing GC of
- // the underlying WebContents wrapper.
+ // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC.
this.policyCleanupByGuestId.set(guest.id, () => {
disposeAntiDetection()
try {
@@ -987,8 +914,7 @@ export class BrowserManager {
options: PopupChildWindowOptions
): Electron.WebContents {
const popup = openPopupWithOriginBar(options, targetUrl)
- // Why: Electron does not emit did-create-window for createWindow-created
- // children, so the opener's policies and routing context attach here.
+ // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here.
this.attachGuestPolicies(
popup.contentWebContents,
this.resolvePopupOwnerContext(openerGuest.id)
@@ -997,8 +923,7 @@ export class BrowserManager {
origin: safeOrigin(targetUrl),
action: 'opened-in-orca'
})
- // Why: parity with Electron's default child-window lifecycle — closing the
- // owning browser tab must not leave orphaned session-bearing popups.
+ // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups.
const closePopupWithOpener = (): void => popup.close()
openerGuest.once('destroyed', closePopupWithOpener)
popup.onClosed(() => {
@@ -1010,10 +935,7 @@ export class BrowserManager {
}
private retireStaleGuestWebContents(previousWebContentsId: number): void {
- // Why: a browser page can re-register with a new guest id after Chromium
- // swaps renderer processes. Late events from the dead guest must stop
- // resolving to the live page, or stale download/popup/permission callbacks
- // can be delivered to the wrong session after the swap.
+ // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session.
this.cleanupGuestPolicyAttachment(previousWebContentsId)
this.tabIdByWebContentsId.delete(previousWebContentsId)
}
@@ -1030,8 +952,7 @@ export class BrowserManager {
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
this.offscreenGuestIds.delete(guestWebContentsId)
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
- // Why: a popup must stop inheriting authorization as soon as its primary
- // owner is retired, even if Chromium has not destroyed the child yet.
+ // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child.
if (isPrimaryGuest) {
for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) {
if (owner.rootGuestWebContentsId === guestWebContentsId) {
@@ -1060,10 +981,7 @@ export class BrowserManager {
if (!browserTabId) {
return false
}
- // Why: re-registering the same browser tab can happen when Chromium swaps
- // or recreates the underlying guest surface. Any active grab is bound to
- // the old guest's listeners and teardown path, so keeping it alive would
- // leave the session attached to a stale webContents until timeout.
+ // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
this.cancelGrabOp(browserTabId, 'evicted')
const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId)
@@ -1077,18 +995,12 @@ export class BrowserManager {
return false
}
- // Why: the renderer sends webContentsId, which we must not blindly trust.
- // A compromised renderer could send the main window's own webContentsId,
- // causing us to overwrite its setWindowOpenHandler or attach unintended
- // context menus. Only accept genuine webview guest surfaces.
+ // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests.
if (guest.getType() !== 'webview') {
return false
}
if (!this.policyAttachedGuestIds.has(webContentsId)) {
- // Why: renderer registration is only the second half of the guest setup.
- // Main must only trust guests that already passed attach-time policy
- // installation; otherwise a trusted renderer could point us at some other
- // arbitrary webview and bypass the intended host-window attach boundary.
+ // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview.
return false
}
@@ -1120,14 +1032,10 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
- // Why: unregistering a guest while a grab is active means the guest is
- // being torn down. Cancel the grab so the renderer gets a clean signal
- // instead of a dangling Promise.
+ // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
- // Why: remove the policy listeners attached in attachGuestPolicies so the
- // callbacks (which close over the guest WebContents) do not prevent GC of
- // the underlying Chromium surface after the guest is destroyed.
+ // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC.
const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId)
if (guestWebContentsId !== undefined) {
this.cleanupGuestPolicyAttachment(guestWebContentsId)
@@ -1153,8 +1061,7 @@ export class BrowserManager {
mouseWheelZoomCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
- // Why: browser downloads are transient per-tab chrome. Closing the owning
- // tab must cancel active writes instead of hiding them behind no UI.
+ // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them.
for (const [downloadId, download] of this.downloadsById.entries()) {
if (download.browserTabId === browserTabId && !download.terminalEvent) {
this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.')
@@ -1169,17 +1076,12 @@ export class BrowserManager {
this.workspaceIdByPageId.delete(browserTabId)
this.sessionProfileIdByPageId.delete(browserTabId)
this.worktreeIdByTabId.delete(browserTabId)
- // Why: drop any pending viewport-op chain for this tab so the Map doesn't
- // retain a resolved promise keyed to a destroyed guest.
+ // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest.
this.viewportOpsByTabId.delete(browserTabId)
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
- // Why: headless orca serve has no renderer window to mount a , so its
- // browser pages are backed by main-process offscreen WebContents instead. This
- // registers such a page into the same resolution maps the bridge/screencast/
- // input handlers read, but skips the webview-only guards and the renderer setup
- // (context menu, grab shortcut, etc.) that assume a renderer-hosted guest.
+ // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup.
registerOffscreenGuest({
browserPageId,
worktreeId,
@@ -1195,8 +1097,7 @@ export class BrowserManager {
if (!guest || guest.isDestroyed()) {
return
}
- // Why: offscreen pages have no renderer webview listeners, so main owns
- // their load-failure lifecycle for remote browser chrome.
+ // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
this.attachGuestPolicies(guest)
const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId)
@@ -1224,10 +1125,7 @@ export class BrowserManager {
}
this.policyAttachedGuestIds.clear()
this.offscreenGuestIds.clear()
- // Why: unregisterGuest only cleans up guests that were registered (have an
- // entry in webContentsIdByTabId). Guests that went through
- // attachGuestPolicies but were never registered still have cleanup closures
- // here — invoke them so their event listeners are removed before clearing.
+ // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here.
for (const cleanup of this.policyCleanupByGuestId.values()) {
cleanup()
}
@@ -1299,9 +1197,7 @@ export class BrowserManager {
}
}
- // Why: both the did-fail-load path and the synthesized certificate failure
- // build a load error from an untrusted URL; centralize the redaction so a
- // future third site cannot forget to strip Kagi session tokens.
+ // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it.
private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError {
return {
code,
@@ -1339,9 +1235,7 @@ export class BrowserManager {
const browserPageId = this.tabIdByWebContentsId.get(webContentsId)
const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null
if (worktreeId) {
- // Why: this runs inside an Electron guest event dispatch; the listener
- // synchronously reconciles mobile-session tabs, and an escaping throw would
- // become a fatal uncaught exception (no catch-all main-process guard).
+ // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception.
try {
this.browserGuestStateChangedListener?.(worktreeId)
} catch (error) {
@@ -1440,8 +1334,7 @@ export class BrowserManager {
try {
item.cancel()
} catch {
- // Why: without a destination, Chromium must not keep writing invisibly;
- // cancellation remains best-effort after surfacing the failure.
+ // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure.
}
return
}
@@ -1454,8 +1347,7 @@ export class BrowserManager {
try {
item.cancel()
} catch {
- // Why: failing setSavePath can leave Electron in a partially finalized
- // state; cancellation is best-effort after Orca has made the UI terminal.
+ // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal.
}
return
}
@@ -1489,8 +1381,7 @@ export class BrowserManager {
download.item.off('updated', updatedHandler)
download.item.off('done', doneHandler)
} catch {
- // Why: completed DownloadItems can already be finalized when cleanup
- // runs. Cleanup must stay best-effort so UI teardown never crashes main.
+ // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main.
}
}
item.on('updated', updatedHandler)
@@ -1510,9 +1401,7 @@ export class BrowserManager {
return true
}
- // Why: guest browser surfaces are intentionally isolated from Orca's preload
- // bridge, so renderer code cannot directly call Electron WebContents APIs on
- // them. Main owns the devtools escape hatch and only after tab→guest lookup.
+ // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup.
async openDevTools(browserTabId: string): Promise {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
@@ -1520,8 +1409,7 @@ export class BrowserManager {
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
- // Why: stale guest discovery must clear every per-tab registry entry,
- // not just the forward/reverse WebContents maps.
+ // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1529,20 +1417,12 @@ export class BrowserManager {
return true
}
- // Why: Electron guests do not expose Chrome DevTools' device
- // toolbar (Cmd+Shift+M) to the embedding app, so viewport emulation must be
- // driven through CDP directly. We reuse the debugger attachment that
- // injectAntiDetection already established and never detach it here — doing
- // so would clear Page.addScriptToEvaluateOnNewDocument and other per-guest
- // overrides. Passing override=null clears emulation.
+ // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared.
async setViewportOverride(
browserTabId: string,
override: BrowserViewportOverride | null
): Promise {
- // Why: chain per-tab so rapid toggles (e.g. user clicking presets quickly)
- // don't interleave CDP commands. Each call waits for the previous one to
- // settle, guaranteeing the last-requested override wins rather than whichever
- // sendCommand sequence happens to finish last.
+ // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins.
const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
@@ -1551,9 +1431,7 @@ export class BrowserManager {
try {
return await next
} finally {
- // Why: only clear if this call's promise is still the tail. A concurrent
- // later call may have already replaced the entry; deleting would drop the
- // chain and break serialization for the next invocation.
+ // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization.
if (this.viewportOpsByTabId.get(browserTabId) === next) {
this.viewportOpsByTabId.delete(browserTabId)
}
@@ -1588,15 +1466,13 @@ export class BrowserManager {
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
- // Why: stale guest discovery must clear every per-tab registry entry,
- // not just the forward/reverse WebContents maps.
+ // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
try {
- // Why: the scroll bridge runs outside the page world so page monkey
- // patches cannot read the per-tab token or tamper with bridge state.
+ // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it.
await guest.executeJavaScriptInIsolatedWorld(
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
[{ code: buildBrowserAnnotationViewportBridgeScript(options) }],
@@ -1618,8 +1494,7 @@ export class BrowserManager {
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
- // Why: stale guest discovery must clear every per-tab registry entry,
- // not just the forward/reverse WebContents maps.
+ // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1629,10 +1504,7 @@ export class BrowserManager {
guest.debugger.attach('1.3')
}
} catch (err) {
- // Why: DevTools being open on the guest causes attach to throw with
- // "Another debugger is already attached". Silently returning false made
- // this failure mode undiagnosable — surface it via the logger with enough
- // context (tab + webContents ids) to correlate with user reports.
+ // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable.
console.warn('[browser-manager] setViewportOverride: failed to attach debugger', {
browserTabId,
webContentsId,
@@ -1656,10 +1528,7 @@ export class BrowserManager {
})
if (override.mobile) {
const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent()))
- // Why: pass userAgentMetadata alongside the mobile UA string so
- // sec-ch-ua-mobile / sec-ch-ua-platform client hints match. Without
- // it, session-level desktop client-hints leak through and create a
- // UA/CH mismatch that bot-detection (Cloudflare, Turnstile) flags.
+ // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak.
await dbg.sendCommand('Emulation.setUserAgentOverride', {
userAgent: buildMobileUserAgent(chromeMajor),
userAgentMetadata: {
@@ -1682,9 +1551,7 @@ export class BrowserManager {
}
})
} else {
- // Why: desktop presets still need the clean (non-Electron) UA so
- // Cloudflare/Turnstile don't flag the session. Passing the cleaned
- // real UA keeps sec-ch-ua consistent with the override.
+ // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
await dbg.sendCommand('Emulation.setUserAgentOverride', {
userAgent: cleanElectronUserAgent(guest.getUserAgent())
})
@@ -1704,14 +1571,9 @@ export class BrowserManager {
}
}
- // ---------------------------------------------------------------------------
- // Browser Context Grab — main-owned operations
- // ---------------------------------------------------------------------------
+ // --- Browser Context Grab — main-owned operations ---
- /**
- * Validates that a caller (identified by sender webContentsId) owns the
- * given browserTabId. Returns the guest WebContents or null.
- */
+ /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
@@ -1726,8 +1588,7 @@ export class BrowserManager {
}
const guest = webContents.fromId(guestId)
if (!guest || guest.isDestroyed()) {
- // Why: stale guest discovery must clear every per-tab registry entry,
- // not just the forward/reverse WebContents maps.
+ // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return null
}
@@ -1739,10 +1600,7 @@ export class BrowserManager {
return this.grabSessionController.hasActiveGrabOp(browserTabId)
}
- /**
- * Enable or disable grab mode for a browser tab. When enabled, injects the
- * overlay runtime into the guest. When disabled, cancels any active grab op.
- */
+ /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */
async setGrabMode(
browserTabId: string,
enabled: boolean,
@@ -1752,10 +1610,7 @@ export class BrowserManager {
this.cancelGrabOp(browserTabId, 'user')
return true
}
- // Why: injecting the overlay runtime eagerly on arm lets the hover UI
- // appear instantly when the user starts moving the pointer, rather than
- // adding a visible delay between "click Grab" and "overlay appears".
- // The runtime is idempotent — re-injection on the same page is safe.
+ // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe.
try {
await guest.executeJavaScript(buildGuestOverlayScript('arm'))
return true
@@ -1765,18 +1620,9 @@ export class BrowserManager {
}
/**
- * Await a single grab selection on the given tab. Returns a Promise that
- * resolves exactly once when the user clicks, cancels, or an error occurs.
+ * Await a single grab selection on the given tab; resolves once on click, cancel, or error.
*
- * Why the click is handled in-guest rather than via main-side interception:
- * Electron's `before-input-event` only fires for keyboard events, not mouse
- * events on guest webContents. The design doc anticipated a main-owned
- * interceptor, but the spike showed this API gap. The fallback (documented
- * in the design doc) is to let the guest overlay's full-viewport hit-catcher
- * consume the click. The overlay calls `stopPropagation()` and
- * `preventDefault()` so the page underneath does not receive the event.
- * This is not a perfect guarantee (capture-phase listeners on window may
- * still fire), but it covers the vast majority of sites.
+ * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click.
*/
awaitGrabSelection(
browserTabId: string,
@@ -1786,17 +1632,12 @@ export class BrowserManager {
return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest)
}
- /**
- * Cancel an active grab operation for the given tab.
- */
+ /** Cancel an active grab operation for the given tab. */
cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void {
this.grabSessionController.cancelGrabOp(browserTabId, reason)
}
- /**
- * Capture a screenshot of the guest surface and optionally crop it to
- * the given CSS-pixel rect.
- */
+ /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */
async captureSelectionScreenshot(
_browserTabId: string,
rect: BrowserGrabRect,
@@ -1805,11 +1646,7 @@ export class BrowserManager {
return captureGrabSelectionScreenshot(rect, guest)
}
- /**
- * Extract the payload for the currently hovered element without disrupting
- * the active grab overlay or awaitClick listener. Used by keyboard shortcuts
- * that let the user copy content while hovering, before clicking.
- */
+ /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */
async extractHoverPayload(
_browserTabId: string,
guest: Electron.WebContents
@@ -1836,13 +1673,7 @@ export class BrowserManager {
)
}
- // Why: browser grab mode intentionally uses Cmd/Ctrl+C as its entry
- // gesture, but a focused webview guest is a separate Chromium process so
- // the renderer's window-level keydown handler never sees that shortcut.
- // Only forward the chord when Chromium would not perform a normal copy:
- // no editable element is focused and there is no selected text. That keeps
- // native page copy working while still making the grab shortcut reachable
- // from focused web content.
+ // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works.
private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId)
if (previousCleanup) {
@@ -1863,11 +1694,7 @@ export class BrowserManager {
)
}
- // Why: a focused webview guest is a separate Chromium process — keyboard
- // events go to the guest's own webContents and never fire the renderer's
- // window-level keydown handler or the main window's before-input-event.
- // Intercept common app shortcuts on the guest and forward them to the
- // renderer so they work consistently regardless of which surface has focus.
+ // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts.
private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void {
const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId)
if (previousCleanup) {
@@ -1913,9 +1740,7 @@ export class BrowserManager {
): void {
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
if (!browserTabId) {
- // Why: some localhost failures happen before the renderer finishes
- // registering which tab owns this guest. Queue the failure by guest ID so
- // registerGuest can replay it instead of silently losing the error state.
+ // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it.
this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError)
return
}
@@ -2110,9 +1935,7 @@ export class BrowserManager {
try {
download.item.cancel()
} catch {
- // Why: DownloadItem.cancel can throw after the item has already
- // finalized. Cleanup here is best-effort because the UI state is the
- // source of truth for whether Orca still considers the request active.
+ // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative.
}
if (shouldSendCancel) {
@@ -2203,9 +2026,7 @@ 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).
+ // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk.
renderer.send('browser:guest-load-failed', {
browserPageId: browserTabId,
loadError: {
@@ -2224,8 +2045,7 @@ export class BrowserManager {
if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) {
return false
}
- // Why: only the renderer owns Orca's worktree/tab model. Main forwards a
- // validated URL instead of letting arbitrary guest content mutate it.
+ // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it.
renderer.send('browser:open-link-in-orca-tab', {
browserPageId: browserTabId,
url: normalizedUrl
diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts
index a70aff4e6b2..72a888edb62 100644
--- a/src/main/browser/browser-session-registry.ts
+++ b/src/main/browser/browser-session-registry.ts
@@ -1,7 +1,4 @@
-/* eslint-disable max-lines -- Why: the registry is the single source of truth for
- browser session profiles, partition allowlisting, cookie import staging, and
- per-partition permission/download policies. Splitting further would scatter the
- security boundary across modules. */
+/* eslint-disable max-lines -- Why: single source of truth for browser session profiles, partition allowlisting, cookie staging, and per-partition policies; splitting scatters the security boundary. */
import { app, session } from 'electron'
import type { Session } from 'electron'
import { randomUUID } from 'node:crypto'
@@ -52,10 +49,7 @@ const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json'
const LEGACY_BROWSER_SESSION_PARTITION_RE =
/^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/
-// Why: the registry is the single source of truth for which Electron partitions
-// are valid. will-attach-webview consults it to decide whether a guest's
-// requested partition is allowed. This prevents a compromised renderer from
-// smuggling an arbitrary partition string into a guest surface.
+// Why: source of truth for valid partitions; will-attach-webview consults it so a compromised renderer can't smuggle in an arbitrary partition.
class BrowserSessionRegistry {
private readonly profiles = new Map()
@@ -86,10 +80,7 @@ class BrowserSessionRegistry {
})
}
- // Why: the default profile's source metadata (what browser was imported,
- // when) must survive app restarts so the Settings UI can show the import
- // status. Cookies themselves persist in the Electron partition's SQLite DB,
- // but the registry is in-memory only.
+ // Why: source metadata must persist across restarts (for the Settings import status) since the registry is in-memory only.
private get metadataPath(): string {
return (
this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME)
@@ -103,13 +94,11 @@ class BrowserSessionRegistry {
private static partitionCookiesPath(partition: string): string {
const partitionName = partition.replace('persist:', '')
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
- // Why: replay must overwrite the same modern or legacy database that the
- // importing Electron partition already uses.
+ // Why: replay must overwrite the same (modern or legacy) DB the importing partition already uses.
return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies')
}
- // Why: write-to-temp-then-rename is atomic on all supported platforms.
- // A crash mid-write would only lose the temp file, not corrupt the live one.
+ // Why: write-temp-then-rename is atomic, so a crash mid-write can't corrupt the live file.
private persistMeta(updates: Partial): void {
try {
const existing = this.loadPersistedMeta()
@@ -129,8 +118,7 @@ class BrowserSessionRegistry {
})
}
- // Why: non-default profiles are in-memory only unless explicitly persisted.
- // Without this, created profiles vanish on app restart.
+ // Why: non-default profiles are in-memory only; without this they vanish on restart.
private persistProfiles(): void {
const nonDefault = [...this.profiles.values()].filter((p) => p.id !== 'default')
this.persistMeta({ profiles: nonDefault })
@@ -178,16 +166,8 @@ class BrowserSessionRegistry {
}
}
- // Why: browser sessions must be initialized BEFORE any webview loads.
- // Permission/download policies must exist before sites request capabilities,
- // and the User-Agent must be set before the first request so imported session
- // cookies are not invalidated by Electron's default UA.
- //
- // Why this also refreshes defaultSource: the singleton constructor runs at
- // module-import time, which may be before app.isReady(). app.getPath('userData')
- // is not guaranteed before ready, so the constructor's loadPersistedSource()
- // silently returns null. Re-reading here after app readiness ensures the
- // default profile's source is populated.
+ // Why: run before any webview loads, and set the UA before the first request or Electron's default UA invalidates imported cookies.
+ // Why re-read defaultSource: the constructor may run before app.isReady() (userData path unavailable), so loadPersistedSource() returned null.
initializeBrowserSessionsFromPersistedState(): void {
const meta = this.loadPersistedMeta()
if (meta.defaultSource) {
@@ -200,11 +180,7 @@ class BrowserSessionRegistry {
this.hydrateFromPersisted(meta.profiles)
}
- // Why: the default partition is created in the constructor but never gets
- // session policies (permission handlers, download handlers, etc.) because
- // hydrateFromPersisted skips the default partition and createProfile never
- // targets it. Without this, clipboard permissions and other guest policies
- // are denied by default in the default browser partition.
+ // Why: nothing else installs policies on the default partition (hydrate skips it), so without this its guest permissions would be denied.
this.setupSessionPolicies(this.defaultPartition)
const partitions = new Set([
@@ -221,8 +197,7 @@ class BrowserSessionRegistry {
continue
}
- // Why: even without an imported session, the default Electron UA contains
- // "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
+ // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
@@ -232,10 +207,7 @@ class BrowserSessionRegistry {
}
}
- // Why: the import writes cookies to a staging DB because CookieMonster holds
- // the live DB's data in memory and would overwrite our changes on its next
- // flush. This method MUST run before any session.fromPartition() call so
- // CookieMonster reads the staged cookies instead of the stale live DB.
+ // Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB.
applyPendingCookieImport(): void {
try {
const meta = this.loadPersistedMeta()
@@ -243,8 +215,7 @@ class BrowserSessionRegistry {
if (pendingEntries.length === 0) {
return
}
- // Why: replay writes to partition-derived file paths, so corrupted
- // metadata must pass the same validation as the webview allowlist.
+ // Why: replay writes to partition-derived paths, so corrupted metadata must pass the same validation as the webview allowlist.
const knownPartitions = new Set([this.defaultPartition])
for (const profile of meta.profiles) {
if (this.isValidPersistedProfile(profile)) {
@@ -267,9 +238,7 @@ class BrowserSessionRegistry {
try {
mkdirSync(join(liveCookiesPath, '..'), { recursive: true })
copyFileSync(stagedPath, liveCookiesPath)
- // Why: SQLite WAL mode stores uncommitted data in sidecar files.
- // Stale WAL/SHM from a previous session could corrupt CookieMonster's
- // read of the freshly swapped DB.
+ // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB.
let sidecarCopyFailed = false
for (const suffix of ['-wal', '-shm']) {
try {
@@ -288,8 +257,7 @@ class BrowserSessionRegistry {
}
}
if (sidecarCopyFailed) {
- // Why: sidecar copy failures can leave an inconsistent replay state.
- // Keep this entry for retry and preserve unrelated entries.
+ // Why: sidecar copy failed → inconsistent replay; keep this entry for retry.
continue
}
for (const ext of ['', '-wal', '-shm']) {
@@ -301,8 +269,7 @@ class BrowserSessionRegistry {
}
delete remainingEntries[partition]
} catch {
- // Why: failed replay for one partition should not drop unrelated entries.
- // Keep this entry for retry next launch.
+ // Why: keep this entry for retry — one partition's failed replay shouldn't drop unrelated entries.
}
}
this.persistMeta({
@@ -365,26 +332,19 @@ class BrowserSessionRegistry {
resolveKnownPartition(profileId: string | null | undefined): string | null {
if (!profileId) {
- // Why: must track the active Orca profile's default partition, not the
- // legacy constant, or non-default profiles would resolve local-default's
- // cookie jar.
+ // Why: use the active Orca profile's default partition, not the legacy constant, or profiles resolve local-default's cookie jar.
return this.defaultPartition
}
return this.profiles.get(profileId)?.partition ?? null
}
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
- // Why: only the constructor may create the default profile. Allowing the
- // renderer to pass scope:'default' would create a second profile sharing
- // the active default partition, causing confusion on delete (clearing storage
- // for the shared partition).
+ // Why: block scope:'default' here — only the constructor makes the default profile; a second one sharing the partition breaks delete.
if (scope === 'default') {
return null
}
const id = randomUUID()
- // Why: partition names are deterministic from the profile id so main can
- // reconstruct the allowlist on restart from persisted profile metadata
- // without needing a separate partition→profile mapping.
+ // Why: deterministic partition-from-id lets main rebuild the allowlist on restart without a separate partition→profile map.
const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id)
const profile: BrowserSessionProfile = {
id,
@@ -436,27 +396,22 @@ class BrowserSessionRegistry {
userAgent: userAgentByPartition[this.defaultPartition] ?? null
})
- // Why: clearing the partition's storage prevents orphaned cookies/cache from
- // lingering after the user deletes an imported or isolated session profile.
+ // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind.
try {
const sess = session.fromPartition(profile.partition)
this.clearSessionPolicies(profile.partition, sess)
await sess.clearStorageData()
await sess.clearCache()
} catch {
- // Why: partition cleanup is best-effort. The profile is already removed
- // from the registry so it won't be allowed by will-attach-webview.
+ // Why: cleanup is best-effort — the profile is already out of the registry, so will-attach-webview blocks it regardless.
}
return true
}
- // Why: clearing cookies from the default partition lets users undo a cookie
- // import without deleting the default profile itself.
+ // Why: lets users undo a cookie import without deleting the default profile itself.
async clearDefaultSessionCookies(): Promise {
try {
- // Why: persist metadata BEFORE clearing storage so that if the app quits
- // mid-clear, the next launch won't show a stale "imported from X" badge
- // for cookies that were partially or fully removed.
+ // Why: persist metadata before clearing storage so a mid-clear quit doesn't leave a stale "imported from X" badge.
const defaultProfile = this.profiles.get('default')
if (defaultProfile) {
this.profiles.set('default', { ...defaultProfile, source: null })
@@ -482,13 +437,7 @@ class BrowserSessionRegistry {
}
}
- // Why: on startup, main must reconstruct the set of valid partitions from
- // persisted session profiles so restored webviews are not denied by
- // will-attach-webview before the renderer mounts them.
- // Why: profiles are deserialized from a JSON file on disk. A corrupted or
- // tampered file could inject an arbitrary partition into the allowlist that
- // will-attach-webview trusts, so we validate the expected shape before
- // registering anything.
+ // Why: validate on-disk profile shape so a tampered JSON file can't inject an arbitrary partition into the will-attach-webview allowlist.
private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile {
if (!profile || typeof profile !== 'object') {
return false
@@ -533,9 +482,7 @@ class BrowserSessionRegistry {
}
}
- // Why: every browser partition needs the same deny-by-default permission
- // and download policies. Keeping the installer here prevents the default
- // partition and imported/isolated partitions from drifting apart.
+ // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
private readonly configuredPartitions = new Set()
private readonly handleWillDownload = (
_event: Electron.Event,
@@ -558,12 +505,7 @@ class BrowserSessionRegistry {
setupClientHintsOverride(sess, cleanUA)
}
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
- // Why: `media` (camera/mic) must defer to macOS TCC instead of being
- // denied outright. Denying at the session layer would make pages inside
- // isolated browser profiles throw NotAllowedError even after the user
- // granted Camera/Microphone to Orca — the same bug we fixed for the
- // default partition. macOS TCC still gates the actual stream, so
- // granting here only forwards what the OS has already authorized.
+ // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
if (permission === 'media') {
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
@@ -619,9 +561,7 @@ class BrowserSessionRegistry {
}
private clearSessionPolicies(partition: string, sess: Session): void {
- // Why: isolated/imported browser partitions can be deleted while the
- // Electron Session object survives; clear policy callbacks and listener
- // bookkeeping so removed profiles do not leave retained closures behind.
+ // Why: the Electron Session survives partition deletion; clear callbacks/listeners so removed profiles don't retain closures.
this.configuredPartitions.delete(partition)
browserManager.removeCertificateRequestGuard(sess)
sess.removeListener('will-download', this.handleWillDownload)
diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts
index bda4831a790..47d263ac371 100644
--- a/src/main/browser/cdp-bridge.ts
+++ b/src/main/browser/cdp-bridge.ts
@@ -67,18 +67,15 @@ type TabState = {
debuggerDetachListener: (() => void) | null
debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null
iframeSessions: Map
- // Why: capture state is per-tab so console/network events from one tab
- // don't pollute another's capture buffer.
+ // Why: capture state is per-tab so one tab's console/network events don't pollute another's buffer.
capturing: boolean
consoleLog: BrowserConsoleEntry[]
networkLog: BrowserNetworkEntry[]
- // Why: interception state tracks patterns and paused requests so the
- // agent can selectively continue or block individual requests.
+ // Why: interception state lets the agent selectively continue or block individual requests.
intercepting: boolean
interceptPatterns: string[]
pausedRequests: Map
- // Why: maps CDP requestId to the networkLog entry so loadingFinished
- // can attribute size to the correct response when requests overlap.
+ // Why: maps CDP requestId → networkLog entry so loadingFinished attributes size to the right overlapping response.
networkRequestMap: Map
}
@@ -123,9 +120,7 @@ export class CdpBridge {
_worktreeId?: string,
browserPageId?: string
): { browserPageId: string; url: string; title: string } | null {
- // Why: OrcaRuntimeService pushes navigation/title updates after commands
- // using a bridge-agnostic contract. The CDP bridge only routes one active
- // tab at a time, but it still needs to expose the same metadata lookup.
+ // Why: expose the same metadata lookup as other bridges, though the CDP bridge routes only one active tab.
const resolvedPageId = browserPageId ?? this.getActivePageId()
if (!resolvedPageId) {
return null
@@ -185,10 +180,7 @@ export class CdpBridge {
const localCenter = await this.getElementCenter(refSender, node.backendDOMNodeId)
const { cx, cy } = await this.getPageCoordinates(guest, node, localCenter.cx, localCenter.cy)
- // Why: mouseMoved fires mouseenter/mouseover which some sites need to
- // reveal hover-dependent menus or clickable areas before the click lands.
- // Input events go to the parent session — Chrome routes them to the
- // correct frame based on coordinates.
+ // Why: mouseMoved fires mouseenter/mouseover so sites reveal hover-dependent menus/targets before the click lands.
await sender('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy })
await sender('Input.dispatchMouseEvent', {
type: 'mousePressed',
@@ -244,9 +236,7 @@ export class CdpBridge {
const toLocal = await this.getElementCenter(toSender, toNode.backendDOMNodeId)
const to = await this.getPageCoordinates(guest, toNode, toLocal.cx, toLocal.cy)
- // Why: 10-step interpolation with delays simulates human-like drag and
- // triggers dragenter/dragover events on intermediate elements, which many
- // drag-and-drop libraries rely on.
+ // Why: interpolate the drag so intermediate elements fire dragenter/dragover, which many drag-and-drop libs require.
await sender('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.cx, y: from.cy })
await sender('Input.dispatchMouseEvent', {
type: 'mousePressed',
@@ -323,8 +313,7 @@ export class CdpBridge {
await refSender('DOM.focus', { backendNodeId: node.backendDOMNodeId })
- // Why: select-all then delete clears any existing value before typing,
- // matching the behavior of Playwright's fill() and agent-browser's fill.
+ // Why: select-all + delete clears the existing value before typing, matching Playwright/agent-browser fill().
await sender('Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'a',
@@ -340,11 +329,8 @@ export class CdpBridge {
await insertTextThroughCdp(sender, value)
- // Why: React and other frameworks use synthetic event listeners that may not
- // detect native keyboard events. Explicitly dispatching input/change ensures
- // controlled components update their state. Uses refSender so that in iframe
- // contexts, document.activeElement resolves to the iframe's focused element
- // rather than the