Merge remote-tracking branch 'origin/main' into brennanb2025/codex-real-home-multi-account-landing

# Conflicts:
#	src/main/agent-hooks/installer-utils.ts
#	src/main/codex-accounts/runtime-home-service.ts
#	src/main/codex-accounts/service.ts
#	src/main/codex/config-toml-trust.test.ts
#	src/main/codex/config-toml-trust.ts
#	src/main/codex/hook-service.ts
#	src/main/daemon/pty-subprocess.ts
#	src/main/index.ts
#	src/main/startup/configure-process.ts
This commit is contained in:
Brennan Benson
2026-07-20 11:20:17 -07:00
471 changed files with 27112 additions and 35526 deletions
+1 -6
View File
@@ -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:
+9 -4
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="License: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -22,6 +22,10 @@
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
<p align="center">
<sub>Windows? Grab the <a href="https://github.com/stablyai/orca/releases#release-v1.4.147-rc.4">latest RC</a> — has Windows fixes.</sub>
</p>
<p align="center">
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner" width="960" />
</p>
@@ -212,7 +216,8 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
### Desktop — macOS, Windows, Linux
- **[Download from onOrca.dev](https://onorca.dev/download)**
- Or grab a build directly: [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/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
- Or grab a build directly: [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.4/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
- **Windows users:** use the [latest RC (`v1.4.147-rc.4`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.4) — it includes Windows-specific bug fixes not yet in stable.
- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md).
_Or via a package manager:_
+3 -2
View File
@@ -36,10 +36,11 @@ function log(message) {
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
}
return Number(match[1])
}
@@ -22,7 +22,10 @@ function log(message) {
}
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const source = readFileSync(
join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'),
'utf8'
)
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('Could not read the daemon protocol version')
@@ -30,19 +33,99 @@ function readProtocolVersion() {
return Number(match[1])
}
function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) {
const socket = connect(socketPath)
let buffer = ''
return new Promise((resolveStream, rejectStream) => {
let handshakeComplete = false
const rejectHandshake = (error) => {
if (handshakeComplete) {
onFailure(error)
return
}
handshakeComplete = true
clearTimeout(timer)
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
socket.destroy()
rejectStream(error)
}
const onError = (error) => rejectHandshake(error)
const onClose = () => rejectHandshake(new Error('Daemon stream socket closed'))
const onData = (chunk) => {
buffer += chunk.toString('utf8')
const newline = buffer.indexOf('\n')
if (newline === -1) {
return
}
const message = JSON.parse(buffer.slice(0, newline))
if (message.type !== 'hello') {
return
}
if (!message.ok) {
rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello'))
return
}
handshakeComplete = true
clearTimeout(timer)
socket.removeListener('data', onData)
// Why: drain terminal events even though this lifecycle repro only asserts through RPC.
socket.on('data', () => {})
resolveStream(socket)
}
const timer = setTimeout(
() => rejectHandshake(new Error('Daemon stream hello timed out')),
requestTimeoutMs
)
socket.on('error', onError)
socket.on('close', onClose)
socket.on('data', onData)
socket.once('connect', () => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId,
role: 'stream'
})}\n`
)
})
})
}
function createRpcClient(socketPath, tokenPath) {
const socket = connect(socketPath)
const clientId = randomUUID()
const protocolVersion = readProtocolVersion()
const token = readFileSync(tokenPath, 'utf8').trim()
const pending = new Map()
let buffer = ''
let requestId = 0
let streamSocket
let connectionError
let helloResolve
let helloReject
let helloTimer
const hello = new Promise((resolveHello, rejectHello) => {
helloResolve = resolveHello
helloReject = rejectHello
helloResolve = () => {
clearTimeout(helloTimer)
resolveHello()
}
helloReject = (error) => {
clearTimeout(helloTimer)
rejectHello(error)
}
})
helloTimer = setTimeout(() => {
helloReject(new Error('Daemon control hello timed out'))
socket.destroy()
}, requestTimeoutMs)
const rejectPending = (error) => {
connectionError ??= error
helloReject(error)
for (const { reject, timer } of pending.values()) {
clearTimeout(timer)
@@ -82,25 +165,40 @@ function createRpcClient(socketPath, tokenPath) {
}
})
const connected = new Promise((resolveConnected, rejectConnected) => {
const socketConnected = new Promise((resolveConnected, rejectConnected) => {
socket.once('connect', resolveConnected)
socket.once('error', rejectConnected)
}).then(() => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: readProtocolVersion(),
token: readFileSync(tokenPath, 'utf8').trim(),
clientId: randomUUID(),
role: 'control'
})}\n`
})
const connected = Promise.all([
socketConnected.then(() => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId,
role: 'control'
})}\n`
)
}),
hello
]).then(async () => {
// Why: v24 only admits terminals for the same complete control+stream pair as production.
streamSocket = await createStreamSocket(
socketPath,
token,
protocolVersion,
clientId,
rejectPending
)
return hello
})
return {
async request(type, payload) {
await connected
if (connectionError) {
throw connectionError
}
const id = `repro-${++requestId}`
return new Promise((resolveRequest, rejectRequest) => {
const timer = setTimeout(() => {
@@ -112,6 +210,7 @@ function createRpcClient(socketPath, tokenPath) {
})
},
close() {
streamSocket?.destroy()
socket.destroy()
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.5m">
<title>downloads: 6.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.7m">
<title>downloads: 6.7m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.5m</text>
<text x="93.5" y="14">6.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.7m</text>
<text x="93.5" y="14">6.7m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+5 -5
View File
@@ -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.
+3 -3
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrellas en GitHub" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="Estrellas en GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Licencia: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
+274
View File
@@ -0,0 +1,274 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Étoiles GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Téléchargements totaux sur toutes les versions" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licence" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Rejoindre le Discord Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Suivre Orca sur X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plateformes prises en charge : macOS, Windows et Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
<strong>L'orchestrateur d'IA pour les builders 100x.</strong><br/>
Lancez Codex, Claude Code, OpenCode ou Pi côte à côte — chacun dans son propre worktree, le tout suivi au même endroit.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Télécharger Orca</ins></a></h3>
<p align="center">
<sub>Sous Windows ? Prenez la <a href="https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3">dernière RC</a> — elle inclut des correctifs Windows.</sub>
</p>
<p align="center">
<img src="../assets/readme-hero.jpg" alt="Application de bureau Orca exécutant des agents dans des worktrees parallèles, avec l'app companion mobile Orca dans le coin" width="960" />
</p>
## Fonctionnalités
<table>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop avec l'app companion mobile" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orchestration de worktrees parallèles" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Splits de terminal" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navigateur intégré et Mode Design" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Workflows GitHub et Linear dans Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees distants via SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Annoter les diffs générés par l'IA" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Glisser des fichiers et images dans le prompt d'un agent" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Scripter Orca depuis la CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**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 &amp; 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.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logo Claude Code" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logo Codex" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logo Grok" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logo Amp" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logo OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logo Antigravity" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logo Pi" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logo oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logo Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logo Devin" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logo Goose" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logo Auggie" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logo Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logo Charm" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logo Cline" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logo Codebuff" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logo Command Code" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logo Continue" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logo Droid" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logo Kilocode" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logo Kimi" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logo Kiro" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logo Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logo Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logo Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ n'importe quel agent CLI</kbd>
</p>
---
## 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é &amp; 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.
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat de la communauté Orca" width="160" />
- **Feedback &amp; idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
- **Confidentialité :** Voir la [doc confidentialité &amp; 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).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contributeurs Orca" />
</a>
<p align="center">
<img src="../assets/star-history.png" alt="Graphique d'historique des étoiles GitHub pour stablyai/orca" width="880" />
</p>
## 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).
+3 -3
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub スター数" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub スター数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="ライセンス: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
+3 -3
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub 스타" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub 스타" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="라이선스: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
+3 -3
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="Estrelas no GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Licença: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a></sub>
</p>
<p align="center">
+3 -3
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub Star 数" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub Star 数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="许可证: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -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: <https://github.com/stablyai/orca/tree/Jinwoo-H/issue-9138-full-ownership-audit-snapshot>
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: <https://github.com/stablyai/orca/issues/9138>
Reviewed design comment by AmethystLiang:
<https://github.com/stablyai/orca/issues/9138#issuecomment-5006601124>
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.
@@ -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.
+1 -1
View File
@@ -179,7 +179,7 @@ export default function RootLayout() {
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="notification-opt-in"
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
+40 -115
View File
@@ -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<Worktree[]>(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<string | null>(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<readonly WorkspaceStatusDefinition[]>(
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<Map<string, string>>(new Map())
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
@@ -201,9 +188,7 @@ export function HostScreen({
}, [router])
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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<MobileViewState>({
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<MobileViewState>) => {
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({
</View>
{connState !== 'connected' &&
(() => {
// Why: status label removed in favor of just the dot +
// Reconnect button — the home screen already surfaces the
// verdict text per host, and the dot color already
// signals severity here. Auth-failed routes through its
// dedicated banner so we still want to suppress the
// Reconnect button for that case.
// 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={
<RefreshControl
refreshing={refreshing}
@@ -1478,10 +1406,7 @@ export function HostScreen({
)
}
// Default route export. On wide tablet/foldable canvases the worktree list is
// rendered as a persistent sidebar by the host layout, so the route itself
// becomes the empty detail pane until a workspace is opened. On phones it is
// the full-screen worktree list as before.
// On wide layouts the sidebar hosts the list, so this route is just the empty detail pane.
export default function HostWorktreeRoute() {
const { isWideLayout } = useResponsiveLayout()
if (isWideLayout) {
@@ -0,0 +1,30 @@
import { Pressable, View } from 'react-native'
import { SquareChevronRight } from 'lucide-react-native'
import { colors } from '../../../../src/theme/mobile-theme'
import { styles } from './mobile-session-styles'
type Props = {
disabled: boolean
onPress: () => void
}
export function QuickCommandsTabButton({ disabled, onPress }: Props) {
return (
<>
<View style={styles.tabActionDivider} />
<Pressable
style={({ pressed }) => [
styles.newTerminalButton,
pressed && styles.newTerminalButtonPressed,
disabled && styles.newTerminalButtonDisabled
]}
disabled={disabled}
onPress={onPress}
accessibilityLabel="Quick commands"
>
<SquareChevronRight size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -135,6 +135,13 @@ export const mobileSessionFrameStyles = StyleSheet.create({
newTerminalButtonDisabled: {
opacity: 0.45
},
// Divider between the + new-terminal button and the Quick Commands launcher,
// matching the tab strip's borderSubtle separators.
tabActionDivider: {
width: StyleSheet.hairlineWidth,
height: 18,
backgroundColor: colors.borderSubtle
},
terminalFrame: {
flex: 1,
minHeight: 0,
+39 -93
View File
@@ -36,7 +36,10 @@ import {
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
@@ -123,10 +126,7 @@ function formatDuration(ms: number): string {
return `${totalMinutes}m`
}
// Why: derive a stable per-instance identity for RpcClient so the wireUp
// effect's dep key changes when forceReconnect swaps the underlying client
// for a host (without this, listeners stay attached to the closed client
// and notifications/accounts subs never re-attach).
// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners.
const clientIdentities = new WeakMap<RpcClient, number>()
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<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(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<Array<{ hostId: string; client: RpcClient }>>([])
// 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<string, number> = { ...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() {
<FlatList
data={sortedHosts}
keyExtractor={(h) => 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({
+215
View File
@@ -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 (
<MobileOnboardingFlow
key={`${hostId ?? 'home'}:${rawSteps ?? 'all'}`}
hostId={hostId}
rawSteps={rawSteps}
/>
)
}
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<MobileOnboardingBusyChoice>(null)
const [error, setError] = useState<string | null>(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 (
<SafeAreaView style={styles.container}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
{steps.length > 1 ? (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel="Onboarding progress"
accessibilityValue={{ text: `Step ${activeIndex + 1} of ${steps.length}` }}
style={styles.progress}
>
{steps.map((step, index) => (
<View
key={step}
style={[styles.progressDot, index === activeIndex && styles.progressDotActive]}
/>
))}
</View>
) : null}
</View>
<View style={styles.carouselViewport}>
<Animated.View
style={[
styles.carouselTrack,
{ width: width * steps.length, transform: [{ translateX }] }
]}
>
{steps.map((step, index) => (
<MobileOnboardingPage
key={step}
step={step}
width={width}
active={index === activeIndex}
busyChoice={busyChoice}
error={error}
onSessionChoice={(view) => void chooseSessionView(view)}
onNotificationChoice={(choice) => void chooseNotifications(choice)}
/>
))}
</Animated.View>
</View>
</SafeAreaView>
)
}
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
}
-236
View File
@@ -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<string | null>(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 (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
</View>
<View style={styles.content}>
<View style={styles.iconSurface}>
<BellRing size={30} color={colors.textPrimary} />
</View>
<Text style={styles.eyebrow}>Notifications</Text>
<Text style={styles.title}>Stay updated while away</Text>
<Text style={styles.body}>
Get notified on this device when an agent needs your input or finishes a task.
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Enable agent notifications"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('enable')}
>
{busyChoice === 'enable' ? (
<ActivityIndicator color={colors.bgBase} />
) : (
<Text style={styles.primaryButtonText}>Enable notifications</Text>
)}
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('skip')}
>
{busyChoice === 'skip' ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Text style={styles.secondaryButtonText}>Not now</Text>
)}
</Pressable>
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
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
}
})
+6 -7
View File
@@ -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
+6 -7
View File
@@ -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
@@ -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, {
@@ -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<string, ScheduledNotificationState>()
// 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<NotificationPerm
granted: status === 'granted',
status,
canAskAgain,
// Why: Android before API 33 has no runtime notification permission, so
// Expo's default "granted" state is capability evidence, not user consent.
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 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<boolean> {
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<void> {
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)
@@ -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')
})
})
@@ -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 (
<ScrollView
style={[styles.page, { width }]}
contentContainerStyle={styles.pageContent}
showsVerticalScrollIndicator={false}
accessibilityElementsHidden={!active}
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
>
<View style={styles.content}>
<View style={styles.iconSurface}>
{isSessionView ? (
<MessageSquare size={30} color={colors.textPrimary} />
) : (
<BellRing size={30} color={colors.textPrimary} />
)}
</View>
<Text style={styles.title}>
{isSessionView ? 'How should sessions open?' : 'Stay updated while away'}
</Text>
<Text style={styles.body}>
{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.'}
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
{isSessionView ? (
<SessionViewChoices busyChoice={busyChoice} disabled={busy} onChoice={onSessionChoice} />
) : (
<NotificationChoices
busyChoice={busyChoice}
disabled={busy}
onChoice={onNotificationChoice}
/>
)}
</View>
</ScrollView>
)
}
function SessionViewChoices({
busyChoice,
disabled,
onChoice
}: {
busyChoice: MobileOnboardingBusyChoice
disabled: boolean
onChoice: (view: MobileSessionView) => void
}) {
return (
<>
<ChoiceButton
label="Use native chat"
accessibilityLabel="Open sessions in native chat"
primary
busy={busyChoice === 'chat'}
disabled={disabled}
onPress={() => onChoice('chat')}
/>
<ChoiceButton
label="Keep terminal"
accessibilityLabel="Open sessions in the terminal"
busy={busyChoice === 'terminal'}
disabled={disabled}
onPress={() => onChoice('terminal')}
/>
</>
)
}
function NotificationChoices({
busyChoice,
disabled,
onChoice
}: {
busyChoice: MobileOnboardingBusyChoice
disabled: boolean
onChoice: (choice: NotificationOnboardingChoice) => void
}) {
return (
<>
<ChoiceButton
label="Enable notifications"
accessibilityLabel="Enable agent notifications"
primary
busy={busyChoice === 'enable'}
disabled={disabled}
onPress={() => onChoice('enable')}
/>
<ChoiceButton
label="Not now"
accessibilityLabel="Skip notifications for now"
busy={busyChoice === 'skip'}
disabled={disabled}
onPress={() => onChoice('skip')}
/>
</>
)
}
function ChoiceButton({
label,
accessibilityLabel,
primary = false,
busy,
disabled,
onPress
}: {
label: string
accessibilityLabel?: string
primary?: boolean
busy: boolean
disabled: boolean
onPress: () => void
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
disabled={disabled}
style={({ pressed }) => [
primary ? styles.primaryButton : styles.secondaryButton,
pressed && styles.buttonPressed,
disabled && styles.buttonDisabled
]}
onPress={onPress}
>
{busy ? (
<ActivityIndicator color={primary ? colors.bgBase : colors.textSecondary} />
) : (
<Text style={primary ? styles.primaryButtonText : styles.secondaryButtonText}>{label}</Text>
)}
</Pressable>
)
}
@@ -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'])
})
})
@@ -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<MobileOnboardingStep[]> {
// 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]
}
@@ -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 })
})
})
@@ -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
}
})
@@ -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<QuickCommandDraft>) => void
onOpenAgentPicker: () => void
onCancel: () => void
onSave: () => void
}
function ActionToggle({
value,
onChange
}: {
value: QuickCommandDraft['action']
onChange: (action: QuickCommandDraft['action']) => void
}) {
return (
<View style={styles.toggleGroup}>
{(['terminal-command', 'agent-prompt'] as const).map((action) => {
const selected = value === action
return (
<Pressable
key={action}
style={({ pressed }) => [
styles.toggleItem,
selected && styles.toggleItemSelected,
pressed && !selected && styles.pressed
]}
onPress={() => onChange(action)}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
{action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'}
</Text>
</Pressable>
)
})}
</View>
)
}
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 (
<View style={styles.form}>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={draft.label}
onChangeText={(label) => onChange({ label })}
placeholder="Start dev server"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Action</Text>
<ActionToggle value={draft.action} onChange={(action) => onChange({ action })} />
</View>
{isAgent ? (
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
style={({ pressed }) => [styles.select, pressed && styles.pressed]}
onPress={onOpenAgentPicker}
accessibilityRole="button"
>
{draft.agent ? (
<View style={styles.selectValue}>
<MobileAgentIcon agentId={draft.agent} size={16} />
<Text style={styles.selectValueText}>{getQuickCommandAgentLabel(draft.agent)}</Text>
</View>
) : (
<Text style={styles.selectPlaceholder}>Choose agent</Text>
)}
<ChevronDown size={16} color={colors.textMuted} />
</Pressable>
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>{isAgent ? 'Prompt' : 'Command Text'}</Text>
<TextInput
style={[styles.input, styles.textarea, !isAgent && styles.mono]}
value={isAgent ? draft.prompt : draft.command}
onChangeText={(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 ? (
<Text style={styles.hint}>Supports skills, file paths, and built-in commands.</Text>
) : null}
</View>
<View style={styles.field}>
<Pressable
style={({ pressed }) => [styles.advancedToggle, pressed && styles.pressed]}
onPress={() => setAdvancedOpen((open) => !open)}
accessibilityRole="button"
accessibilityState={{ expanded: advancedOpen }}
>
{advancedOpen ? (
<ChevronDown size={16} color={colors.textSecondary} />
) : (
<ChevronRight size={16} color={colors.textSecondary} />
)}
<Text style={styles.advancedText}>Advanced</Text>
</Pressable>
{advancedOpen ? (
<View style={styles.advancedBody}>
{!isAgent ? (
<View style={styles.switchRow}>
<View style={styles.switchText}>
<Text style={styles.switchTitle}>Append Enter</Text>
<Text style={styles.switchDesc}>
Submit immediately instead of only inserting text.
</Text>
</View>
<Switch
value={draft.appendEnter}
onValueChange={(appendEnter) => onChange({ appendEnter })}
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
thumbColor={colors.surfaceBright}
/>
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>Scope</Text>
<View style={styles.toggleGroup}>
{(['global', 'repo'] as const).map((scopeType) => {
const selected = draft.scope.type === scopeType
const disabled = scopeType === 'repo' && !hasRepoScope
return (
<Pressable
key={scopeType}
disabled={disabled}
style={({ pressed }) => [
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 }}
>
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
{scopeType === 'global' ? 'Global' : 'Project'}
</Text>
</Pressable>
)
})}
</View>
{draft.scope.type === 'repo' && repoName ? (
<Text style={styles.scopeRepoName}>{repoName}</Text>
) : null}
</View>
</View>
) : null}
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.footer}>
<Pressable
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
onPress={onCancel}
accessibilityRole="button"
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.button, styles.saveButton, !canSave && styles.saveButtonDisabled]}
disabled={!canSave}
onPress={onSave}
accessibilityRole="button"
>
<Text style={[styles.saveText, !canSave && styles.saveTextDisabled]}>
{mode === 'edit' ? 'Save' : 'Add Quick Command'}
</Text>
</Pressable>
</View>
</View>
)
}
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 }
})
@@ -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: <T>(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)
})
})
+332
View File
@@ -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 (
<View style={styles.listBody}>
{showSearch ? (
<View style={styles.search}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={onQueryChange}
placeholder="Search quick commands..."
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Why: bound pasted text before it reaches the per-keystroke JS
// search path; useful queries are far smaller than this budget.
maxLength={QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH}
/>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading && !hasVisible ? (
<ActivityIndicator style={styles.loading} color={colors.textSecondary} />
) : null}
{!loading && totalCount === 0 ? (
<Text style={styles.empty}>No quick commands yet.</Text>
) : null}
{!loading && totalCount > 0 && !hasVisible ? (
<Text style={styles.empty}>No matching quick commands.</Text>
) : null}
{repoCommands.length > 0 ? (
<QuickCommandGroup
label="This project"
commands={repoCommands}
onLaunch={onLaunch}
onEdit={onEdit}
onDelete={onDelete}
disabled={disabled}
/>
) : null}
{globalCommands.length > 0 ? (
<QuickCommandGroup
label="Global"
commands={globalCommands}
onLaunch={onLaunch}
onEdit={onEdit}
onDelete={onDelete}
disabled={disabled}
/>
) : null}
<Pressable
style={({ pressed }) => [
styles.addRow,
addDisabled && styles.disabled,
pressed && !addDisabled && styles.pressed
]}
disabled={addDisabled}
onPress={onAdd}
accessibilityRole="button"
>
<Plus size={18} color={colors.textSecondary} />
<Text style={styles.addText}>
{canAdd ? 'New quick command' : 'Quick command limit reached'}
</Text>
</Pressable>
</View>
)
}
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 (
<View>
<Text style={styles.groupLabel}>{label}</Text>
<View style={styles.group}>
{commands.map((command, index) => (
<QuickCommandRow
key={command.id}
command={command}
first={index === 0}
onLaunch={onLaunch}
onEdit={onEdit}
onDelete={onDelete}
disabled={disabled}
/>
))}
</View>
</View>
)
}
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 (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
export function QuickCommandAgentPicker({
selected,
onSelect
}: {
selected: TuiAgent | null
onSelect: (agent: TuiAgent) => void
}) {
return (
<View style={styles.group}>
{QUICK_COMMAND_SUPPORTED_AGENTS.map((agent, index) => (
<Pressable
key={agent.id}
style={({ pressed }) => [
styles.row,
index > 0 && styles.rowBorder,
pressed && styles.pressed
]}
onPress={() => onSelect(agent.id)}
accessibilityRole="button"
accessibilityState={{ selected: selected === agent.id }}
>
<View style={styles.rowIcon}>
<MobileAgentIcon agentId={agent.id} size={16} />
</View>
<Text style={styles.agentLabel}>{agent.label}</Text>
{selected === agent.id ? <Check size={16} color={colors.textPrimary} /> : null}
</Pressable>
))}
</View>
)
}
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 }
})
@@ -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: <T>(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<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((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<boolean>()
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 })
})
})
+251
View File
@@ -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<SheetView>('list')
const [query, setQuery] = useState('')
const [draft, setDraft] = useState<QuickCommandDraft | null>(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 (
<BottomDrawer visible={visible} onClose={onClose}>
<View style={styles.header}>
{view === 'list' ? (
<View style={styles.backSpacer} />
) : (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />
</Pressable>
)}
<Text style={styles.title}>{title}</Text>
<View style={styles.backSpacer} />
</View>
{view === 'editor' && draft ? (
<View style={styles.editorDesc}>
<Text style={styles.descText}>
Save terminal commands or agent prompts for quick access.
</Text>
</View>
) : null}
{view === 'list' ? (
<QuickCommandsList
repoCommands={repoCommands}
globalCommands={globalCommands}
totalCount={searchableCommands.length}
query={query}
loading={loading}
disabled={!ready}
canAdd={commands.length < MAX_QUICK_COMMANDS}
error={error}
onQueryChange={setQuery}
onLaunch={handleLaunch}
onEdit={openEditor}
onDelete={handleDelete}
onAdd={() => openEditor()}
/>
) : null}
{view === 'editor' && draft ? (
<QuickCommandEditorForm
draft={draft}
mode={draft.id ? 'edit' : 'add'}
saving={saving || !ready}
error={error}
repoId={repoId}
repoName={repoName}
onChange={(patch) =>
setDraft((current) => (current ? { ...current, ...patch } : current))
}
onOpenAgentPicker={() => setView('agent')}
onCancel={() => {
setView('list')
setDraft(null)
}}
onSave={() => void handleSave()}
/>
) : null}
{view === 'agent' && draft ? (
<QuickCommandAgentPicker
selected={draft.agent}
onSelect={(agent) => {
setDraft((current) => (current ? { ...current, agent } : current))
setView('editor')
}}
/>
) : null}
</BottomDrawer>
)
}
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 }
})
+116
View File
@@ -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
}
}
@@ -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)
})
})
@@ -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<boolean> {
const preference = await readDefaultSessionViewPreference()
return preference.loaded && !preference.hasStoredValue
}
@@ -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<PrSidebarState>({ 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<string | null>(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
}
@@ -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<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('useQuickCommands', () => {
let renderer: ReactTestRenderer | null = null
let state: ReturnType<typeof useQuickCommands> | 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<void> {
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<RpcResponse>()
const secondUpdate = deferred<RpcResponse>()
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<boolean> = Promise.resolve(false)
let secondPersist: Promise<boolean> = 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<RpcResponse>()
const secondUpdate = deferred<RpcResponse>()
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<boolean> = Promise.resolve(false)
let secondPersist: Promise<boolean> = 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<RpcResponse>()
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<boolean> = 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<RpcResponse>()
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<boolean> = 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<RpcResponse>()
const secondUpdate = deferred<RpcResponse>()
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<boolean> = Promise.resolve(false)
let secondPersist: Promise<boolean> = 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')
})
})
+212
View File
@@ -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<boolean>
}
type PendingMutation = {
id: number
mutation: TerminalQuickCommandMutation
}
type MutationContext = {
client: RpcClient
confirmed: TerminalQuickCommand[]
pending: PendingMutation[]
queue: Promise<void>
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<TerminalQuickCommand[]>([])
const [loading, setLoading] = useState(false)
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const commandsRef = useRef<TerminalQuickCommand[]>([])
const operationIdRef = useRef(0)
const mutationContextRef = useRef<MutationContext | null>(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<boolean> => {
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 }
}
@@ -46,16 +46,13 @@ export function MobileSourceControlPanel({
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(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<ReadonlySet<SourceControlHubTab>>(
() => new Set<SourceControlHubTab>([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<string | null>(null)
const lastPrHeadRef = useRef<string | null>(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({
<Pressable
style={styles.retryButton}
onPress={() => {
// 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({
</View>
) : 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({
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{/* 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) ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
@@ -357,8 +323,7 @@ export function MobileSourceControlPanel({
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
// Gate on the probe too: isGithubRepo=false mid-probe must render as
// loading, not flash "unavailable for this provider" (old /pr parity).
// Gate on the probe too: isGithubRepo=false mid-probe must render loading, not flash "unavailable".
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
controller={prController}
/>
+34
View File
@@ -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' })
+19 -4
View File
@@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
}
}
/** Global (per-device) default for how supported agent sessions open. */
export async function loadDefaultSessionView(): Promise<MobileSessionView> {
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<DefaultSessionViewPreference> {
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<MobileSessionView> {
return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW
}
export function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.
@@ -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> = {}): 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 }
})
})
})
+105
View File
@@ -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: <prompt>", 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)}…`
}
+16 -59
View File
@@ -28,14 +28,9 @@ type TerminalViewportRefitOptions = {
initializedHandlesRef: RefObject<Set<string>>
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])
+3 -13
View File
@@ -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 = `<!DOCTYPE html>
<html>
<head>
@@ -1892,6 +1883,5 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
</body>
</html>`
// 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 }
+23 -94
View File
@@ -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<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
// entire tree on every connection state change. State propagation goes
// through per-host listener Sets instead.
// 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<Map<string, StoreEntry>>(new Map())
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
// Pending opens (avoid two acquire() callers in the same render racing the
// host lookup). Keyed by hostId, value is a sentinel resolved when the
// entry materialises.
// 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<Map<string, HostProfile>>(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<void> {
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
+20 -57
View File
@@ -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<string | null> {
// 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<void> {
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<string, string>()
let inflightLoad: Promise<HostProfile[]> | 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<void> = 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<HostProfile[]> {
// 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<HostProfile[]> {
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<StoredHostProfile[]> {
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<void> {
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<void> {
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.
}
}
+40 -152
View File
@@ -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<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | 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
+1 -1
View File
@@ -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",
+24 -77
View File
@@ -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<string, string | string[]> = {}
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<string, CommandHandler> = {
!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<OrchestrationSendResult>('orchestration.send', {
from,
@@ -383,14 +361,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
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<string, CommandHandler> = {
'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<string, CommandHandler> = {
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<string, CommandHandler> = {
try {
result = await client.call<CheckResult>('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<string, CommandHandler> = {
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<string, CommandHandler> = {
...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<string, CommandHandler> = {
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<string, CommandHandler> = {
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<string, CommandHandler> = {
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<string, CommandHandler> = {
'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
@@ -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')
})
})
+91
View File
@@ -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')
})
})
+136
View File
@@ -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<ActiveViewFile>
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<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | 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<void> {
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<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
}
@@ -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<boolean>
/** 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<string>()
const settledWorktreeIds = new Set<string>()
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<boolean> {
// 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:<uuid>` 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:<uuid>` 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)
}
+20 -68
View File
@@ -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 "<missing path>"` on every tool call, which exits 127 and surfaces
// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
// transcript. 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, string> = {}): string {
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
// literally — avoids a shell-injection footgun if a future caller passes an
// arbitrary path.
// 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, string> = {}
): 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
// <event> 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 <scriptPath>` 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.
}
}
+34 -117
View File
@@ -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)
+82 -263
View File
@@ -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<AgentKind>(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<typeof createServer> | 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<StatusChangeListener>()
// 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<string>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
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<typeof setTimeout> | null = null
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
@@ -530,9 +467,7 @@ export class AgentHookServer {
private closedAgentStatusTabIds = new Set<string>()
private closedAgentStatusPaneKeys = new Set<string>()
private connectionTimestampWatermarkById = new Map<string, number>()
// 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<void>((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<string, EnrichedAgentHookEventPayload> = {}
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,
+63 -177
View File
@@ -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<string, number>()
private activeWebContentsId: number | null = null
private readonly sessions = new Map<string, SessionState>()
private readonly commandQueues = new Map<string, QueuedCommand[]>()
private readonly processingQueues = new Set<string>()
// 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<void> = 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<string, string[]>()
// 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<string, Promise<void>>()
// 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<string, Promise<void>>()
private readonly cancelledProcesses = new WeakSet<ChildProcess>()
@@ -634,14 +612,12 @@ export class AgentBrowserBridge {
newWebContentsId: number,
previousWebContentsId?: number
): Promise<void> {
// 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<BrowserTabSwitchResult> {
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 <id>` 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<BrowserFillResult> {
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<BrowserReloadResult> {
// 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<BrowserScreenshotResult> {
// 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<BrowserPdfResult> {
// 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<unknown> {
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<void> => {
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<void> => {
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<unknown> {
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<void> {
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)
+42 -118
View File
@@ -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 '<Browser> Safe Storage'/'<Browser>' convention —
// its Keychain entry is literally service 'Helium Storage Key', account 'Helium'.
// Why: Helium breaks the '<Browser> 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 <random>.<name> (e.g. "abc123.default-release").
// Prefer 'default-release' as it's the primary user profile on most installs.
// Why: Firefox dirs are named <random>.<name>; 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<string>()
// 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<string | null> {
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<typeof DatabaseSync> | 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 {
+28 -95
View File
@@ -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)
+81 -261
View File
@@ -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<string, number>()
// 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<number, string>()
private readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
// 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<string, string>()
private readonly sessionProfileIdByPageId = new Map<string, string | null>()
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
// 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<string, Promise<unknown>>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
@@ -252,9 +236,7 @@ export class BrowserManager {
private readonly policyCleanupByGuestId = new Map<number, () => void>()
private readonly clickedLinkFrameNameByGuestId = new Map<number, string>()
private readonly loadErrorsByGuestId = new Map<number, BrowserLoadError>()
// 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<number, BrowserLoadError>()
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<typeof setTimeout> | 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 <webview>, 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 <webview> 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<boolean> {
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 <webview> 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<boolean> {
// 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
+26 -86
View File
@@ -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<string, BrowserSessionProfile>()
@@ -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<BrowserSessionMeta>): 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<boolean> {
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<string>()
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)
+41 -120
View File
@@ -67,18 +67,15 @@ type TabState = {
debuggerDetachListener: (() => void) | null
debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null
iframeSessions: Map<string, string>
// 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<string, BrowserInterceptedRequest>
// 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<string, BrowserNetworkEntry>
}
@@ -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 <iframe> element on the parent page.
// Why: React's synthetic listeners ignore native key events, so dispatch input/change so controlled components update.
// Why: use refSender for iframe sessions so document.activeElement is the focused element inside the iframe, not the parent <iframe>.
const eventSender = node.sessionId ? refSender : sender
await eventSender('Runtime.evaluate', {
expression: `(() => {
@@ -388,11 +374,7 @@ export class CdpBridge {
object: { objectId: string }
}
// Why: agent-browser matches by both .value AND .textContent.trim() so
// the agent can select options by their displayed label, not just the
// underlying value attribute which may be an opaque ID.
// Also handles custom combobox elements (role="combobox" on non-<select>)
// by falling back to click-based selection on child options.
// Why: match on label (textContent) and value so opaque option IDs still select; also handles combobox via click.
await refSender('Runtime.callFunctionOn', {
objectId: object.objectId,
functionDeclaration: `function(val) {
@@ -433,9 +415,7 @@ export class CdpBridge {
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
// Why: JS scrollBy is deterministic and doesn't require page focus,
// unlike Input.dispatchMouseEvent mouseWheel which is unreliable in
// Electron webviews and can timeout.
// Why: JS scrollBy needs no focus and is deterministic, unlike mouseWheel which is unreliable in Electron webviews.
const expr = amount
? `window.scrollBy(0, ${direction === 'down' ? amount : -amount})`
: `window.scrollBy(0, ${direction === 'down' ? 'window.innerHeight' : '-window.innerHeight'})`
@@ -501,10 +481,7 @@ export class CdpBridge {
clickCount: 1
})
// Why: custom checkbox patterns (label wrapping hidden input, overlays)
// may not toggle from coordinate-based clicking. Verify state changed
// and fall back to programmatic .click() if needed. Wrapped in try/catch
// because the click may have triggered a re-render that invalidated objectId.
// Why: custom checkboxes may not toggle from a coordinate click; verify state and fall back to programmatic .click().
try {
const { result: afterState } = (await refSender('Runtime.callFunctionOn', {
objectId: object.objectId,
@@ -639,10 +616,7 @@ export class CdpBridge {
cssContentSize?: { width: number; height: number }
contentSize?: { width: number; height: number }
}
// Why: Page.captureScreenshot clip coordinates are CSS pixels. On HiDPI
// displays, Electron can report device-pixel `contentSize`, which causes
// Chromium to tile the page into duplicated quadrants. Prefer
// `cssContentSize` so the clip matches the page's CSS layout size.
// Why: screenshot clip uses CSS pixels; on HiDPI, device-pixel contentSize tiles duplicates, so prefer cssContentSize.
const contentSize = metrics.cssContentSize ?? metrics.contentSize
if (!contentSize) {
throw new BrowserError('browser_error', 'Unable to determine full-page screenshot bounds')
@@ -699,10 +673,7 @@ export class CdpBridge {
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
// Why: Network.setCookie requires either domain or url to scope the cookie.
// If the caller doesn't provide a domain, infer it from the current page URL
// so the cookie is usable on the active page without forcing callers to
// know the domain upfront.
// Why: Network.setCookie needs a domain or url to scope the cookie; infer the domain from the current page when omitted.
let domain = cookie.domain
if (!domain) {
const { result: urlResult } = (await sender('Runtime.evaluate', {
@@ -747,8 +718,7 @@ export class CdpBridge {
if (url) {
params.url = url
}
// Why: Network.deleteCookies requires at least one of domain or url.
// Infer from current page if neither was provided.
// Why: Network.deleteCookies needs a domain or url; infer from the current page if neither was given.
if (!domain && !url) {
const { result: urlResult } = (await sender('Runtime.evaluate', {
expression: 'location.href',
@@ -781,8 +751,7 @@ export class CdpBridge {
deviceScaleFactor,
mobile
})
// Why: BrowserView's compositor surface can keep the previous host size
// after metrics-only resize, which crops remote screencast clients.
// Why: metrics-only resize can leave the compositor surface at the old size, cropping remote screencast clients.
await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {})
return { width, height, deviceScaleFactor, mobile }
@@ -852,9 +821,7 @@ export class CdpBridge {
return { requests: [...state.pausedRequests.values()] }
}
// TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request
// interception decisions. The CDP Fetch domain supports it, but the agent-browser CLI
// only operates on URL pattern-level routing, creating a design mismatch.
// TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions (CLI is URL-pattern-only).
// ── Console/network capture ──
@@ -975,8 +942,7 @@ export class CdpBridge {
const valueStr =
result.value !== undefined ? String(result.value) : (result.description ?? '')
// Why: agent-browser returns { result, origin } — match that shape so both
// bridges satisfy the same BrowserEvalResult contract.
// Why: include origin to match agent-browser's BrowserEvalResult shape across both bridges.
const { result: urlResult } = (await sender('Runtime.evaluate', {
expression: 'location.origin',
returnByValue: true
@@ -1011,8 +977,7 @@ export class CdpBridge {
}
async tabSwitch(index: number): Promise<BrowserTabSwitchResult> {
// Why: filter to live tabs so indices match what tabList() presents.
// Destroyed-but-not-yet-cleaned entries must be skipped.
// Why: filter to live tabs so indices match tabList(), skipping destroyed-but-uncleaned entries.
const liveEntries = [...this.getRegisteredTabs()].filter(([_, wcId]) => {
const guest = webContents.fromId(wcId)
return guest && !guest.isDestroyed()
@@ -1061,10 +1026,7 @@ export class CdpBridge {
if (guest && !guest.isDestroyed()) {
return guest
}
// Why: the stored webContentsId may be stale after a Chromium process swap
// (navigation to a different-origin page, crash recovery). Fall through to
// the auto-select logic rather than immediately failing, since the tab may
// still be alive under a new webContentsId.
// Why: webContentsId goes stale after a process swap; fall through to auto-select since the tab may have a new id.
this.activeWebContentsId = null
}
@@ -1096,11 +1058,7 @@ export class CdpBridge {
}
private getRegisteredTabs(): Map<string, number> {
// Why: BrowserManager's tab maps are private. We access the singleton's
// state via the public getGuestWebContentsId method by iterating known tabs.
// This method provides the tab enumeration the CDP bridge needs without
// modifying BrowserManager's encapsulation. In the future a public
// listTabs() method on BrowserManager would be cleaner.
// Why: reach into BrowserManager's private tab map since it exposes no public listTabs().
return (this.browserManager as unknown as { webContentsIdByTabId: Map<string, number> })
.webContentsIdByTabId
}
@@ -1176,9 +1134,7 @@ export class CdpBridge {
}
try {
// Why: browser tabs are already debugger-attached by BrowserManager's
// anti-detection injection. Reusing that attachment lets CDP commands
// run instead of failing with "another debugger is already attached."
// Why: BrowserManager already attached the debugger; reuse it to avoid "another debugger is already attached."
if (!guest.debugger.isAttached()) {
guest.debugger.attach('1.3')
}
@@ -1194,24 +1150,19 @@ export class CdpBridge {
await sender('DOM.enable')
await sender('Network.enable')
// Why: cross-origin iframes run in separate renderer processes (OOPIFs) and
// are invisible to the parent's CDP session. setAutoAttach with flatten:true
// gives each OOPIF its own sessionId that we can target with sendCommand.
// Why: OOPIF iframes are invisible to the parent CDP session; flatten:true gives each a targetable sessionId.
await sender('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true
})
// Why: attaching the CDP debugger sets navigator.webdriver = true and
// exposes other automation signals that Cloudflare Turnstile checks.
// Override them on every new document load so challenges succeed.
// Why: CDP attach exposes automation signals (navigator.webdriver) that Cloudflare checks; override per new document.
await sender('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
// Why: only remove CDP bridge listeners from the previous attach cycle.
// Screencast/proxy sessions share this debugger and own their teardown hooks.
// Why: only remove this bridge's listeners; screencast/proxy sessions share the debugger and own their teardown.
this.removeDebuggerListeners(guest, state)
const detachListener = (): void => {
@@ -1226,8 +1177,7 @@ export class CdpBridge {
state.snapshotResult = null
state.navigationId = null
}
// Why: an unhandled alert/confirm/prompt blocks ALL subsequent CDP commands.
// Auto-dismissing prevents the session from hanging indefinitely.
// Why: an unhandled JS dialog blocks all subsequent CDP commands; auto-dismiss to avoid hanging.
if (method === 'Page.javascriptDialogOpening') {
const dialog = params as { type: string; message: string } | undefined
guest.debugger
@@ -1236,8 +1186,7 @@ export class CdpBridge {
})
.catch(() => {})
}
// Why: track cross-origin iframe sessions so we can route CDP commands
// and accessibility tree queries to the correct session.
// Why: track iframe sessions so CDP commands and AX queries route to the correct session.
if (method === 'Target.attachedToTarget') {
const p = params as
| {
@@ -1247,7 +1196,6 @@ export class CdpBridge {
| undefined
if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) {
state.iframeSessions.set(p.targetInfo.targetId, p.sessionId)
// Enable domains on iframe session
guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {})
guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {})
guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {})
@@ -1264,8 +1212,7 @@ export class CdpBridge {
}
}
}
// Why: console and network events are buffered per-tab so the agent can
// retrieve them on demand via the capture commands.
// Why: buffer console/network events per-tab so the agent can retrieve them on demand.
if (state.capturing) {
if (method === 'Runtime.consoleAPICalled') {
const p = params as
@@ -1314,8 +1261,7 @@ export class CdpBridge {
timestamp: p.timestamp ?? Date.now()
}
state.networkLog.push(entry)
// Why: track requestId→entry so loadingFinished can attribute
// size to the correct response, not just the most recent one.
// Why: map requestId→entry so loadingFinished attributes size to the right response, not the latest one.
if (p.requestId) {
state.networkRequestMap.set(p.requestId, entry)
}
@@ -1343,8 +1289,7 @@ export class CdpBridge {
}
}
}
// Why: request interception buffers paused requests so the agent can
// inspect and decide to continue or block them.
// Why: buffer paused requests so the agent can later inspect and continue or block them.
if (state.intercepting && method === 'Fetch.requestPaused') {
const p = params as
| {
@@ -1376,10 +1321,7 @@ export class CdpBridge {
private makeCdpSender(guest: Electron.WebContents, sessionId?: string): CdpCommandSender {
return (method: string, params?: Record<string, unknown>) => {
const command = guest.debugger.sendCommand(method, params, sessionId) as Promise<unknown>
// Why: Electron's CDP sendCommand can hang indefinitely if the debugger
// session is stale (e.g. after a renderer process swap that wasn't detected).
// A 10s timeout prevents the RPC from blocking until the CLI's socket timeout.
// The timer is cancelled on success/failure to avoid dangling timer accumulation.
// Why: Electron's CDP sendCommand can hang on a stale debugger session, so a 10s timeout bounds the RPC.
let timer: ReturnType<typeof setTimeout>
return Promise.race([
command.finally(() => clearTimeout(timer)),
@@ -1421,9 +1363,7 @@ export class CdpBridge {
)
}
// Why: iframe refs use a child session whose navigation history is independent
// of the parent page. Checking the parent's navId against an iframe session
// would always mismatch and falsely reject valid refs.
// Why: iframe refs use a child session with independent nav history, so a parent-navId check would falsely reject them.
if (!entry.sessionId) {
const currentNavId = await this.getNavigationId(sender)
if (state.navigationId && currentNavId !== state.navigationId) {
@@ -1441,10 +1381,7 @@ export class CdpBridge {
await refSender('DOM.describeNode', { backendNodeId: entry.backendDOMNodeId })
return entry
} catch {
// Why: dynamic pages (React, Vue) re-render DOM nodes frequently. A ref
// from a snapshot taken seconds ago may point to a detached node even though
// an identical element exists. Re-query the AX tree to find the fresh node
// by matching role + name, avoiding a wasted retry for the agent.
// Why: dynamic pages re-render nodes, detaching snapshot refs; re-query the AX tree by role+name for the fresh node.
const recovered = await this.tryRecoverRef(refSender, entry)
if (recovered) {
entry.backendDOMNodeId = recovered
@@ -1480,10 +1417,7 @@ export class CdpBridge {
return { cx: (x1 + x3) / 2, cy: (y1 + y3) / 2 }
}
// Why: elements inside cross-origin iframes report coordinates relative to
// the iframe viewport, but Input.dispatchMouseEvent operates on the parent
// page's coordinate space. We find the iframe element on the parent page and
// add its top-left offset to translate iframe-local coords to page coords.
// Why: cross-origin iframes report iframe-local coords, but Input events use parent-page space; add the iframe offset.
private async getIframeOffset(
guest: Electron.WebContents,
sessionId: string
@@ -1495,9 +1429,7 @@ export class CdpBridge {
for (const [targetId, sid] of state.iframeSessions) {
if (sid === sessionId) {
try {
// Why: use Target.getTargetInfo to get the iframe's URL, then match it
// against DOM iframe src attributes to find the correct element's position.
// This correctly handles pages with multiple iframes.
// Why: match the iframe's target URL against DOM iframe src to pick the right element on multi-iframe pages.
const { targetInfo } = (await parentSender('Target.getTargetInfo', {
targetId
})) as { targetInfo: { url?: string } }
@@ -1526,8 +1458,7 @@ export class CdpBridge {
return { offsetX: rect.x, offsetY: rect.y }
}
}
// Why: iframe may have redirected after load, making src differ
// from the actual target URL. Match by origin as a fallback.
// Why: iframe may redirect after load so src differs from target URL; match by origin as a fallback.
try {
const targetOrigin = new URL(targetUrl).origin
for (const rect of rects) {
@@ -1554,8 +1485,7 @@ export class CdpBridge {
return { offsetX: 0, offsetY: 0 }
}
// Why: for elements inside iframes, translates iframe-local coordinates to
// page-level coordinates that Input.dispatchMouseEvent expects.
// Why: Input.dispatchMouseEvent uses parent-page coords, so translate iframe-local coords for iframe elements.
private async getPageCoordinates(
guest: Electron.WebContents,
refEntry: RefEntry,
@@ -1569,9 +1499,7 @@ export class CdpBridge {
return { cx: localCx + offsetX, cy: localCy + offsetY }
}
// Why: uses nth-index to pick the correct duplicate when multiple elements
// share the same role+name. Without this, recovery always returns the first
// match which may not be the element the ref originally pointed to.
// Why: nth-index disambiguates duplicate role+name matches so recovery hits the original element, not the first match.
private async tryRecoverRef(sender: CdpCommandSender, entry: RefEntry): Promise<number | null> {
try {
const { nodes } = (await sender('Accessibility.getFullAXTree')) as {
@@ -1626,9 +1554,7 @@ export class CdpBridge {
}
private async waitForLoad(sender: CdpCommandSender, guest: Electron.WebContents): Promise<void> {
// Why: wait for document.readyState=complete first, then wait for network
// idle (no pending requests for 500ms). This handles SPAs that fire 'load'
// before async content is rendered.
// Why: SPAs fire 'load' before async content renders, so also wait for 500ms of network idle.
const TIMEOUT_MS = 25_000
const IDLE_MS = 500
const startedAt = Date.now()
@@ -1792,9 +1718,7 @@ export class CdpBridge {
}
}
// Why: CDP's Input.dispatchKeyEvent requires `windowsVirtualKeyCode` (not `keyCode`)
// and `text` for keys that produce default browser actions (Enter submits forms,
// Tab moves focus). Without `text`, Chrome fires the event but doesn't perform the action.
// Why: Input.dispatchKeyEvent needs `text` for keys with default actions (Enter/Tab), or Chrome skips the action.
type KeyDefinition = {
key: string
code: string
@@ -1823,10 +1747,7 @@ function resolveKeyDefinition(key: string): KeyDefinition {
if (KEY_DEFINITIONS[key]) {
return KEY_DEFINITIONS[key]
}
// Why: single characters need proper code values — digits use "DigitN",
// letters use "KeyX", and other characters use their char code for the
// windowsVirtualKeyCode. Invalid codes cause events to be dropped by sites
// that check event.code.
// Why: sites that check event.code drop events with invalid code values.
if (key.length === 1) {
const charCode = key.charCodeAt(0)
if (charCode >= 48 && charCode <= 57) {
+6 -38
View File
@@ -1,20 +1,6 @@
/* eslint-disable max-lines -- Why: the guest overlay runtime is a single
self-contained JS string template that must be injected atomically into the
guest page. Splitting it across modules would require a string concatenation
build step that adds complexity without improving auditability. */
// ---------------------------------------------------------------------------
// Browser Context Grab — guest overlay runtime builder
//
// This module produces self-contained JavaScript strings that main injects into
// browser guests via executeJavaScript(). The guest runtime is intentionally
// ephemeral: it installs on arm, resolves once on finalize, and fully removes
// itself on teardown.
//
// Why a string builder rather than a bundled file: Orca's browser guests have
// no preload and no Node access. The injected code must be a plain JS string
// that runs in the page's own world. Keeping it as a template here lets main
// version it alongside the rest of the grab lifecycle.
// ---------------------------------------------------------------------------
/* eslint-disable max-lines -- the guest overlay runtime is one self-contained JS string injected atomically; splitting it adds a concat build step for no auditability gain. */
// Browser Context Grab — builds self-contained JS strings injected into guests via executeJavaScript().
// Why a string builder not a bundle: guests have no preload/Node; injected code must be plain JS in the page's own world.
type GuestScriptAction = 'arm' | 'awaitClick' | 'finalize' | 'extractHover' | 'teardown'
@@ -42,10 +28,7 @@ export function buildGuestOverlayScript(action: GuestScriptAction): string {
}
}
// ---------------------------------------------------------------------------
// The arm script installs the overlay container and hover tracking.
// It stores state on window.__orcaGrab so finalize/teardown can access it.
// ---------------------------------------------------------------------------
// arm: install the overlay + hover tracking; state lives on window.__orcaGrab so finalize/teardown can reach it.
const ARM_SCRIPT = `(function() {
'use strict';
@@ -839,11 +822,7 @@ const ARM_SCRIPT = `(function() {
return true;
})()`
// ---------------------------------------------------------------------------
// The awaitClick script returns a Promise that resolves when the user clicks
// on the full-viewport overlay. The click never reaches the page because the
// overlay host has pointer-events:all and the handler calls stopPropagation.
// ---------------------------------------------------------------------------
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
'use strict';
var grab = window.__orcaGrab;
@@ -919,9 +898,6 @@ const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
};
})`
// ---------------------------------------------------------------------------
// The finalize script extracts the payload for the currently hovered element.
// ---------------------------------------------------------------------------
const FINALIZE_SCRIPT = `(function() {
'use strict';
var grab = window.__orcaGrab;
@@ -939,12 +915,7 @@ const FINALIZE_SCRIPT = `(function() {
return payload;
})()`
// ---------------------------------------------------------------------------
// The extractHover script reads the payload for the currently hovered element
// WITHOUT cleaning up. The overlay and awaitClick listener stay active so the
// user can continue picking elements. Used by keyboard shortcuts (C/S) that
// copy the hovered element without requiring a click first.
// ---------------------------------------------------------------------------
// extractHover: read payload but keep overlay/listeners active so the user can keep picking (C/S shortcut copy, no click).
const EXTRACT_HOVER_SCRIPT = `(function() {
'use strict';
var grab = window.__orcaGrab;
@@ -958,9 +929,6 @@ const EXTRACT_HOVER_SCRIPT = `(function() {
}
})()`
// ---------------------------------------------------------------------------
// The teardown script removes the overlay and cleans up all state.
// ---------------------------------------------------------------------------
const TEARDOWN_SCRIPT = `(function() {
'use strict';
var grab = window.__orcaGrab;
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: Claude account switching has one safety
boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and
env-patch semantics together prevents PTY launch and quota fetch paths drifting. */
/* eslint-disable max-lines -- Why: keeps file/Keychain/snapshot/env-patch auth semantics together so PTY launch and quota-fetch paths can't drift. */
import { execFileSync } from 'node:child_process'
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
@@ -98,10 +96,7 @@ export class ClaudeRuntimeAuthService {
private readonly pathResolver = new ClaudeRuntimePathResolver()
private mutationQueue: Promise<unknown> = Promise.resolve()
private lastSyncedAccountId: string | null = null
// Why: tracks the credentials Orca last wrote to the shared credentials file.
// On managed→system-default transition, if the file differs from this value,
// an external login (e.g. `claude auth login`) overwrote it — so Orca adopts
// the file as the new system default instead of restoring a stale snapshot.
// Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default.
private lastWrittenCredentialsJson: string | null = null
private hasMaterializedRuntimeAuth = false
private hasLastWrittenOauthAccount = false
@@ -217,17 +212,13 @@ export class ClaudeRuntimeAuthService {
previousManagedOauthAccount
)
) {
// Why: switching away while Claude is live must preserve verified
// token refreshes before replacing the shared runtime credentials.
// Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials.
await this.writeManagedCredentials(
previousAccount,
outgoingReadBackResult.runtimeCredentialsJson
)
} else {
// Why: Claude's runtime credential blob can lack enough identity
// proof to attribute a live-session refresh. Do not persist that
// unverified blob, but also do not block the user from moving new
// terminals to the selected managed account.
// Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account.
console.warn(
'[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts'
)
@@ -297,9 +288,7 @@ export class ClaudeRuntimeAuthService {
})
return
}
// Why: WSL managed Claude accounts are already isolated by their Linux
// CLAUDE_CONFIG_DIR. Materializing them into Windows ~/.claude would mix
// two runtime auth stores and break the Terminal-default runtime contract.
// Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores.
this.clearLastWrittenRuntimeState()
return
}
@@ -364,10 +353,7 @@ export class ClaudeRuntimeAuthService {
)
}
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
// .credentials.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
// Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting.
if (this.lastSyncedAccountId === activeAccount.id) {
if (this.skipNextReadBackForAccountId === activeAccount.id) {
this.skipNextReadBackForAccountId = null
@@ -383,9 +369,7 @@ export class ClaudeRuntimeAuthService {
} else if (
readBackResult.status === 'rejected' &&
readBackResult.runtimeCredentialsChanged &&
// Why: a live Claude that lost a refresh-token race wipes its runtime
// blob (empty tokens). Preserving that wreckage would leave every new
// session logged out — rematerialize managed credentials instead.
// Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session.
readBackResult.hasValidChangedRuntimeCredentials &&
hasLiveClaudePtys()
) {
@@ -398,13 +382,11 @@ export class ClaudeRuntimeAuthService {
this.readManagedOauthAccount(activeAccount)
)
) {
// Why: this Claude process was launched under the active managed
// account, but persistence still needs positive account proof.
// Why: this Claude launched under the active managed account, but persistence still needs positive account proof.
await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson)
credentialsJson = readBackResult.runtimeCredentialsJson
} else {
// Why: while Claude is running, an unknown refresh can still belong
// to a live session. Rewriting stale managed auth logs that session out.
// Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out.
console.warn(
'[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running'
)
@@ -420,14 +402,7 @@ export class ClaudeRuntimeAuthService {
this.skipNextReadBackForAccountId = null
}
// Why: own the OAuth refresh whenever no live `claude` owns these
// credentials — both switching into an account and re-syncing the active
// account with an expired token. A single-use refresh token is rotated and
// persisted to managed storage atomically before we materialize it, so the
// runtime never gets a stale token that fails with invalid_grant. Skipped
// entirely while a Claude PTY is live: that process owns the credentials
// and refreshing here would race its own rotation (double-rotation
// invalidates one copy) — the read-back above preserves its refresh instead.
// Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead.
const liveClaudePtys = hasLiveClaudePtys()
if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) {
this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id
@@ -445,8 +420,7 @@ export class ClaudeRuntimeAuthService {
const paths = this.pathResolver.getRuntimePaths()
this.writeRuntimeCredentials(credentialsJson)
if (process.platform === 'darwin') {
// Why: Claude Code 2.1+ reads the scoped service, while older builds read
// the legacy unsuffixed service. Runtime switching must satisfy both.
// Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both.
try {
await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir)
} catch (error) {
@@ -469,10 +443,7 @@ export class ClaudeRuntimeAuthService {
this.hasMaterializedRuntimeAuth = true
}
// Why: called by ClaudeAccountService before syncForCurrentSelection() after
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
// Why: re-auth/add-account write fresh managed tokens; skip the next read-back so stale runtime tokens can't overwrite them.
clearLastWrittenCredentialsJson(
accountId = this.store.getSettings().activeClaudeManagedAccountId
): void {
@@ -526,14 +497,7 @@ export class ClaudeRuntimeAuthService {
if (match.kind !== 'matched') {
continue
}
// Why: on cold app start we cannot tell whether matching runtime
// credentials are a fresh CLI refresh or stale state. Adopt when the
// token expiry proves runtime is newer, OR the refresh token rotated
// and runtime is not provably older. A rotated refresh token with
// equal/missing expiry is a genuine CLI refresh we'd otherwise drop
// (stranding a stale managed token); but if expiry proves runtime is
// older, managed already holds the newer token (e.g. a prior read-back
// or proactive refresh), so reject it.
// Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed.
if (this.lastWrittenCredentialsJson === null) {
const fresher = this.runtimeCredentialsAreFresher(
runtimeContents.credentialsJson,
@@ -587,16 +551,13 @@ export class ClaudeRuntimeAuthService {
}
return { status: 'persisted' }
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
// Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle).
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
return {
status: 'rejected',
runtimeCredentialsChanged:
this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson),
// Why: an fs error hides whether a live session's refresh is present,
// so err toward preserving runtime state like before.
// Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state.
hasValidChangedRuntimeCredentials: true
}
}
@@ -723,8 +684,7 @@ export class ClaudeRuntimeAuthService {
settings = this.store.getSettings()
): ClaudeAccountSelectionTarget {
if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') {
// Why: account auth defaults follow account runtime settings, not hidden
// legacy terminal WSL settings that can outlive the Terminal UI control.
// Why: auth defaults follow account runtime settings, not legacy terminal WSL settings that can outlive the Terminal UI.
return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null }
}
return { runtime: 'host' }
@@ -799,9 +759,7 @@ export class ClaudeRuntimeAuthService {
return 'mismatch'
}
// Why: this mirrors the Codex runtime-home guard. If another Claude login
// or missed live process rewrites shared runtime credentials, do not
// persist those credentials into the selected managed account.
// Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them.
const selectedOrganizationUuid = this.normalizeField(
account.organizationUuid ??
managedIdentity?.organizationUuid ??
@@ -1075,14 +1033,11 @@ export class ClaudeRuntimeAuthService {
/**
* Proactively refresh an account's OAuth token and persist the rotation to
* managed storage. Returns the refreshed credentials JSON when a rotation was
* stored, or null when no refresh happened (token still valid, no refresh
* token, or the network call failed — in which case the caller keeps the
* existing credentials, never worse than before).
* managed storage. Returns the refreshed credentials JSON, or null when no
* refresh happened (token valid, no refresh token, or network failure).
*
* Caller guarantees this account is not the live/active one and runs inside
* the serialized mutation queue, so a single-use refresh token can't be
* rotated concurrently.
* Caller guarantees this account isn't the live/active one and runs inside the
* serialized mutation queue, so the single-use refresh token can't rotate concurrently.
*/
private async refreshManagedAccountTokenIfNeeded(
account: ClaudeManagedAccount,
@@ -1271,9 +1226,7 @@ export class ClaudeRuntimeAuthService {
previouslyWrittenCredentialsJson
)
let hasCredentialSurfaceOwnership = fileCredentialsOwned
// Why: runtime auth restore is two-phase: prove ownership before mutating
// any surface, then restore OAuth first. If OAuth fails, the credential
// proof remains intact for retry.
// Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry.
this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson
let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null
let legacySnapshot: ClaudeKeychainSnapshotValue | null = null
@@ -1326,9 +1279,7 @@ export class ClaudeRuntimeAuthService {
if (this.hasLastWrittenOauthAccount) {
return this.lastWrittenOauthAccount
}
// Why: persisted managed metadata is an account identity hint, not proof
// that Orca wrote .claude.json. Use it only after another surface proves
// the current runtime auth still belongs to the managed account.
// Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership.
if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) {
return ownedOauthAccount
}
@@ -1779,10 +1730,7 @@ export class ClaudeRuntimeAuthService {
private writeRuntimeCredentials(contents: string): void {
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
mkdirSync(dirname(credentialsPath), { recursive: true })
// Why: repeated Claude spawns sync auth, but credentials rarely change.
// Skipping unchanged rewrites avoids Windows EPERM contention in #1507.
// Still verify the file because another Claude process may have rewritten
// runtime credentials since Orca last materialized them.
// Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it.
if (
this.lastWrittenCredentialsJson === contents &&
this.fileContentsEqual(credentialsPath, contents)
@@ -1838,8 +1786,7 @@ export class ClaudeRuntimeAuthService {
return parsed as Record<string, unknown>
}
} catch {
// Why: invalid config is an unknown external state. Replacing it with a
// fresh object could silently erase user or Claude-owned settings.
// Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings.
return null
}
return null
+15 -64
View File
@@ -54,24 +54,14 @@ function getManagedScript(
'setlocal',
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default. Skip Orca's managed
// Claude hook there so status posts stay attributed to Devin.
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
]
: []),
// Why: the endpoint file holds the *live* port/token for this Orca
// install. A PTY that survived an Orca restart has stale PORT/TOKEN
// baked into its env from the old instance — loading `endpoint.cmd`
// (`set KEY=VALUE` lines) via `call` refreshes them so the hook
// reaches the current server. Falls through to PTY env if the file
// is missing (first run / pre-endpoint-file / running outside Orca).
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
// Why: post via curl.exe, not a second PowerShell. Claude's launcher is
// already an encoded PowerShell command (Git Bash needs it to survive
// spaces); a PowerShell post on top of that meant two interpreter
// startups per hook. The post runs inside the .cmd (cmd.exe context), so
// curl works the same here as for the POSIX/Codex hooks.
// Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook.
buildWindowsAgentHookCurlPostCommand('claude'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
@@ -84,40 +74,22 @@ function getManagedScript(
...buildPosixHookPayloadCapture(),
...(options.skipWhenDevinImportsClaude
? [
// Why: Devin imports .claude hooks by default. Skip Orca's managed
// Claude hook there so status posts stay attributed to Devin.
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
'if [ -n "$DEVIN_PROJECT_DIR" ]; then',
' exit 0',
'fi'
]
: []),
// Why: the endpoint file holds the *live* port/token for this Orca
// install. PTYs that survive an Orca restart have stale PORT/TOKEN
// baked into their env from the old instance — sourcing the file here
// lets us reach the new server. Falls back to PTY env if the file is
// missing (first-run / pre-endpoint-file scripts / running outside Orca).
// Why: suppress stderr on the `.` builtin. A TOCTOU race (endpoint unlinked
// between the `[ -r ]` test and the source) or a malformed line (e.g. CRLF
// bled in from a cross-platform userData copy) would otherwise print a
// parse error that agent transcripts could surface. Stale coords → dead
// port → silent-fail is the documented fail-open path anyway — the env-var
// guards below handle the empty PORT/TOKEN case — so swallowing the noise
// here is strictly better than leaking shell errors into the hook output.
// `|| :` defends against an eventual `set -e` in an outer script context
// (not present today) aborting the hook on a parse error.
// Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing.
// Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
// Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse.
// Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
@@ -155,10 +127,7 @@ export class ClaudeHookService {
}
}
// Why: Report `partial` when only some managed events are registered so the
// sidebar surfaces a degraded install rather than a false-positive
// `installed`. Each CLAUDE_EVENTS entry must contain the managed command for
// the integration to function end-to-end.
// Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`.
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
@@ -219,24 +188,13 @@ export class ClaudeHookService {
return this.getStatus()
}
// Why: install Orca's Claude hook settings on the remote box rather than the
// local machine. Caller passes the user's SFTP handle plus the resolved
// remote `$HOME`; POSIX-only by design (Windows-remote deferred).
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths
// and a `.sh` managed script body. The remote platform is gated by the
// relay's capability RPC at a higher layer; we cannot detect it from
// `process.platform` here (that's the local box).
// Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this.
const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings)
const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings)
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}`
// Why: SFTP reads/writes fail far more often than local fs (network drops,
// EACCES on remote dirs, disk full, channel closed). Wrap the entire
// install flow in try/catch so a transient I/O failure surfaces as a
// structured `state: 'error'` result for the UI, not an unstructured
// rejection the caller has to remember to handle. A `null` config
// specifically means "file present but unparseable" — keep that branch
// distinct so the user sees an actionable message.
// Why: SFTP I/O fails often (network/EACCES/disk); wrap install so transient failures surface as structured state:'error' rather than an unhandled rejection.
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
@@ -249,19 +207,12 @@ export class ClaudeHookService {
}
}
// Why: the POSIX wrapper is identical regardless of where the script
// lands; only the path differs. Reuse the same wrapper helper.
// Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs.
const command = getRemoteManagedCommand(remoteScriptPath)
const nextConfig = applyManagedHooks(config, command, remoteScriptFileName)
// Why: write the script first, then the settings — settings.json
// referencing a missing script body would fire `command not found` on
// every tool call until the user re-runs install. Doing it in this
// order means a partial-failure mid-install at worst leaves the user
// with a working script no settings.json points at (a no-op), instead
// of broken settings.json.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
// Why: write script before settings — a mid-install failure then leaves a harmless orphan script, not settings.json pointing at a missing one.
// Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from the local OS.
await writeManagedScriptRemote(
sftp,
remoteScriptPath,
+25 -69
View File
@@ -105,13 +105,7 @@ export class CliInstaller {
this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null
this.commandPathOverride =
options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null
// Why: resolved once at construction — existsSync must not run on every
// getStatus() call (hot path). /usr/local/bin is absent by default on Apple
// Silicon Macs (Homebrew moved to /opt/homebrew); fall back to ~/.local/bin
// which is user-writable, requires no elevated permissions, and is the
// XDG-standard user bin dir already on PATH via shell init on arm64.
// defaultMacCommandPath is a test seam: it feeds into the existence check
// so tests can simulate arm64 without relying on the real /usr/local/bin.
// Why: resolved once here (getStatus is hot); /usr/local/bin is absent on Apple Silicon, so fall back to user-writable ~/.local/bin.
const candidateMacPath = options.defaultMacCommandPath ?? DEFAULT_MAC_COMMAND_PATH
this.macCommandPath = existsSync(dirname(candidateMacPath))
? candidateMacPath
@@ -202,21 +196,15 @@ export class CliInstaller {
await this.installAppImageWrapper(status.commandPath, status.launcherPath)
await this.removeLegacyLinuxCommandIfManaged(status.launcherPath)
} else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) {
// Why: packaged Windows already ships resources/bin/orca.exe. Registration
// only owns the user PATH entry; rewriting the asset makes it recurse.
// Why: packaged Windows already ships resources/bin/orca.exe; registration only owns the PATH entry.
} else {
// Why: mkdir stays here for the Windows wrapper path — the target dir is
// user-writable (%LOCALAPPDATA%) so EACCES cannot occur. The symlink path
// handles its own mkdir inside installSymlink so EACCES triggers the
// privileged-runner instead of propagating as an unhandled rejection.
// Why: the Windows wrapper dir is user-writable (%LOCALAPPDATA%), so mkdir here can't hit EACCES.
await mkdir(dirname(status.commandPath), { recursive: true })
await this.installWindowsWrapper(status.commandPath, status.launcherPath)
}
if (this.platform === 'win32') {
// Why: Windows shells discover commands via the user PATH, not by walking
// arbitrary app install directories. The CLI installer therefore owns the
// user-scoped PATH entry instead of assuming the desktop installer did it.
// Why: Windows shells find commands via user PATH, so the installer owns that entry, not the desktop installer.
await this.ensureWindowsPathEntry(dirname(status.commandPath))
}
@@ -319,12 +307,10 @@ export class CliInstaller {
const status = await this.inspectSymlink(commandPath, launcherPath)
if (status.state !== 'not_installed') {
if (reachedDefaultCommandPath && !isDefaultCommandPath && status.state === 'conflict') {
// Why: a non-Orca command after an empty/default install slot can be
// shadowed safely by installing there; no user file needs replacing.
// Why: a non-Orca command after an empty default slot can be shadowed by installing there; no user file replaced.
continue
}
// Why: PATH lookup is first-match-wins; use the executable command the
// shell will actually run, while preserving conflicts that shadow Orca.
// Why: PATH lookup is first-match-wins; return the command the shell will actually run, preserving shadowing conflicts.
return commandPath
}
}
@@ -345,8 +331,7 @@ export class CliInstaller {
}
if (!this.isPackaged) {
// Why: default dev registration is a separate command, while tests and
// diagnostics can still exercise production paths via commandPathOverride.
// Why: dev uses a separate command; tests/diagnostics still reach production paths via commandPathOverride.
if (this.platform === 'darwin') {
return `/usr/local/bin/${DEV_COMMAND_NAME}`
}
@@ -363,18 +348,13 @@ export class CliInstaller {
}
if (this.platform === 'linux') {
// Why: Linux does not have a single privileged global shell-command flow
// equivalent to macOS's /usr/local/bin integration. ~/.local/bin is the
// least surprising user-scoped location that many distros already expose.
// Why `orca-ide`: GNOME Orca (the screen reader) ships /usr/bin/orca on
// most Linux distros. Using `orca-ide` avoids shadowing that system
// command, matching the executableName already used for the Electron binary.
// Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir.
// Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader.
return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME)
}
if (this.platform === 'win32') {
// Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged
// resources directory is the authoritative native launcher location.
// Why: NSIS /D installs can live outside LOCALAPPDATA, so use the packaged resources dir as authoritative.
return getBundledLauncherPath(this.platform, this.resourcesPath)
}
@@ -412,10 +392,7 @@ export class CliInstaller {
if (status.state === 'stale') {
await unlink(status.commandPath as string)
}
// Why: mkdir is placed here (not in install()) so that an EACCES/EPERM
// failure — e.g. /usr/local/bin absent on Intel Mac — falls into the
// privileged-runner catch below instead of surfacing as an unhandled
// rejection that leaves Settings silently showing "not installed".
// Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below.
await mkdir(dirname(status.commandPath as string), { recursive: true })
await symlink(status.launcherPath as string, status.commandPath as string)
} catch (error) {
@@ -423,10 +400,7 @@ export class CliInstaller {
throw error
}
// Why: macOS shell-command registration should behave like VS Code and
// place a stable symlink in /usr/local/bin instead of rewriting shell rc
// files. Fallback to an elevated shell command keeps the public command
// stable even when the app lacks direct write access to that directory.
// Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied.
await this.privilegedRunner(
`mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` +
`ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}`
@@ -465,8 +439,7 @@ export class CliInstaller {
return
}
// Why: after the Linux command rename, the old Orca-owned `orca` symlink
// would keep shadowing GNOME Orca even though the new command is installed.
// Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca.
await unlink(legacyCommandPath)
} catch (error) {
if (isMissingError(error)) {
@@ -492,8 +465,7 @@ export class CliInstaller {
return true
}
// Why: AppImage upgrades can leave a legacy symlink into a now-gone FUSE
// mount; the stable AppImage path is not a sibling of that old target.
// Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path.
return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget)
}
@@ -502,8 +474,7 @@ export class CliInstaller {
}
private async installAppImageWrapper(commandPath: string, appImagePath: string): Promise<void> {
// Why: unlike macOS symlink install, AppImage uses the user-writable Linux
// command dir and must create it before writing the wrapper file.
// Why: the AppImage command dir is user-writable, so create it before writing the wrapper.
await mkdir(dirname(commandPath), { recursive: true })
await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), {
encoding: 'utf8',
@@ -644,8 +615,7 @@ export class CliInstaller {
}
if (this.platform === 'darwin') {
// Why: prior packaged installs can leave a symlink to an older Orca.app
// resources launcher, but arbitrary user-owned symlinks must not be replaced.
// Why: reclaim symlinks to an older Orca.app launcher, but never replace arbitrary user-owned symlinks.
return /(?:^|[/\\])[^/\\]+\.app[/\\]Contents[/\\]Resources[/\\]bin[/\\][^/\\]+$/.test(
resolvedTarget
)
@@ -670,8 +640,7 @@ export class CliInstaller {
const siblingDevUserDataPath = `${packagedUserDataPath}-dev`
const siblingDevLauncherDir = resolve(siblingDevUserDataPath, ...DEV_LAUNCHER_DIR)
// Why: development builds generate launchers under the sibling `*-dev`
// profile; packaged Orca must be able to reclaim that public command.
// Why: dev builds generate launchers under the sibling `*-dev` profile; packaged Orca must reclaim that command.
return (
basename(siblingDevUserDataPath) === `${basename(packagedUserDataPath)}-dev` &&
isPathInsideOrEqual(siblingDevLauncherDir, resolvedTarget)
@@ -901,13 +870,11 @@ export class CliInstaller {
if (result.state === 'success') {
return { value: result.value, expandable: result.expandable }
}
// Why: PATH updates are read-modify-write; continuing after an unknown read
// could replace the user's existing PATH with an incomplete value.
// Why: PATH is read-modify-write; continuing after a failed read could clobber the user's PATH with a partial value.
throw new Error(`${result.detail} No PATH changes were made.`)
}
// Why: raw PowerShell errors reach the UI, so translate denied PATH writes
// while preserving the original diagnostic as the error cause.
// Why: raw PowerShell errors reach the UI, so translate denied PATH writes (keeping the original as cause).
private async writeWindowsUserPathEntry(
value: string,
pathDirectory: string,
@@ -954,10 +921,7 @@ async function ensureDevLauncher(args: {
)
await mkdir(dirname(launcherPath), { recursive: true })
// Why: packaged Orca ships real platform launchers under resources/bin, but
// development builds do not have that stable asset layout. Generating a
// launcher in userData lets us validate the shell-command flow without
// changing the packaged registration contract.
// Why: dev builds lack the packaged resources/bin launcher, so generate one in userData to validate the flow.
const content =
args.platform === 'win32'
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
@@ -967,9 +931,7 @@ async function ensureDevLauncher(args: {
mode: args.platform === 'win32' ? undefined : 0o755
})
if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') {
// Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned
// commands are documented as `orca ...`. Keep that local alias fresh
// without claiming the global production command.
// Why: dev PTYs prepend this dir to PATH, so keep a local `orca` alias without claiming the global command.
await writeFile(join(dirname(launcherPath), 'orca'), content, {
encoding: 'utf8',
mode: 0o755
@@ -1045,9 +1007,7 @@ function extractManagedUnixLauncherTarget(content: string): string | null {
return null
}
// Why: older dev installs wrote a generated shell launcher directly to
// /usr/local/bin/orca. Treat only Orca's compiled CLI entrypoints as managed;
// arbitrary user scripts that happen to launch Electron must stay conflicts.
// Why: only Orca's compiled CLI entrypoints count as managed; arbitrary Electron-launching scripts stay conflicts.
return /(?:^|[/\\])(?:out|app\.asar\.unpacked[/\\]out)[/\\]cli[/\\]index\.js$/.test(cliPath)
? cliPath
: null
@@ -1153,8 +1113,7 @@ function isMissingError(error: unknown): boolean {
)
}
// Why: localized permission errors retain these .NET/ACL markers even when
// their human-readable PowerShell text is mojibake.
// Why: localized permission errors keep these .NET/ACL markers even when the PowerShell text is mojibake.
function isWindowsUserPathPermissionError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
@@ -1199,9 +1158,7 @@ async function writeWindowsUserPath(value: string): Promise<void> {
await runWindowsPathCommand([
'-NoProfile',
'-Command',
// Why: PATH registration must stay user-scoped on Windows so the Orca
// desktop app can manage the public shell command without requiring
// elevation or mutating machine-wide environment state.
// Why: user-scoped PATH avoids requiring elevation or mutating machine-wide state.
`[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')`
])
}
@@ -1224,8 +1181,7 @@ function runWindowsPathCommand(args: string[]): Promise<string> {
resolve(stdout)
}
// Why: Windows PATH reads/writes back CLI Settings; wedged PowerShell must
// not keep command registration status or install/remove pending forever.
// Why: bound wedged PowerShell so PATH reads/writes can't leave CLI registration pending forever.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`))
+31 -91
View File
@@ -1,7 +1,4 @@
/* eslint-disable max-lines -- Why: this service owns the single runtime-home
contract for Codex inside Orca. Keeping path resolution, system-default
snapshots, auth materialization, and recovery together prevents account-switch
semantics from drifting across PTY launch, login, and quota fetch paths. */
/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */
import {
appendFileSync,
copyFileSync,
@@ -112,20 +109,11 @@ function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessE
}
export class CodexRuntimeHomeService {
// Why: tracks whether the runtime auth.json currently mirrors a managed
// account. When null, runtime auth follows the user's system-default
// ~/.codex/auth.json instead of being written back to a managed account.
// Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account.
private lastSyncedAccountId: string | null = null
// Why: tracks the auth.json content Orca last wrote to the runtime CODEX_HOME.
// Between syncs, if the file differs, Codex CLI refreshed the token — so
// Orca writes back the refreshed token to managed storage before overwriting.
// On managed→system-default transition, if the file differs, an external
// login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as
// the new system default instead of restoring a stale snapshot.
// Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt).
private lastWrittenAuthJson: string | null = null
// Why: WSL terminals have their own stable runtime homes per distro. They
// cannot share the host baseline or host sync can make stale WSL auth look
// newer than managed storage.
// Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage.
private readonly lastWrittenWslAuthJsonByDistro = new Map<string, string | null>()
private readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
@@ -148,9 +136,7 @@ export class CodexRuntimeHomeService {
settings.codexManagedAccounts,
normalizeCodexRuntimeSelection(settings).host
)
// Why: WSL-managed homes are never materialized into host ~/.codex.
// Treating one as "last synced" makes cold start look like a host-account
// transition and can restore/delete host auth that Orca never touched.
// Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched.
this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount)
? null
: normalizeCodexRuntimeSelection(settings).host
@@ -194,8 +180,7 @@ export class CodexRuntimeHomeService {
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
// Why: historical Codex sessions can be large; bridge them after launch
// setup so starting a fresh Codex TUI never waits on a full tree walk.
// Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk.
void startSystemCodexSessionBridgeInBackground(
{},
resolveHostCodexSessionSourceHome(this.store.getSettings())
@@ -343,16 +328,14 @@ export class CodexRuntimeHomeService {
if (!distro) {
return
}
// Why: history-only override lets custom-CODEX_HOME users bridge from their
// real home; falls back to <wslHome>/.codex, which auth/config still use.
// Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to <wslHome>/.codex.
const systemCodexHomePath =
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
return
}
// Why: WSL history must be hardlinked inside the distro; host-side links
// cannot bridge Windows and WSL filesystems in a resume-visible way.
// Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way.
void startWslCodexSessionBridgeInBackground({
distro,
systemCodexHomePath,
@@ -466,8 +449,7 @@ export class CodexRuntimeHomeService {
if (!systemHomePath || systemHomePath === runtimeHomePath) {
return
}
// Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring
// cannot provide the distro user's global instructions.
// Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions.
syncCodexGlobalInstructionsIntoManagedHome({
systemHomePath,
managedHomePath: runtimeHomePath
@@ -582,10 +564,7 @@ export class CodexRuntimeHomeService {
}
})
}
// Why: only restore the system-default mirror when transitioning FROM a
// managed account. When no managed account was ever active, later syncs
// should mirror the user's current ~/.codex/auth.json instead of
// replaying an old snapshot on every PTY launch / rate-limit fetch.
// Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot.
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot({
detectExternalLogin: outgoingReadBackResult !== 'rejected'
@@ -601,16 +580,12 @@ export class CodexRuntimeHomeService {
) {
this.restoreSystemDefaultSnapshot({ detectExternalLogin: false })
} else if (logoutMarkerStatus.kind === 'system-default-changed') {
// Why: a real ~/.codex logout after a local runtime logout should
// keep runtime auth absent instead of restoring the stale snapshot.
// Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot.
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker(null)
this.lastWrittenAuthJson = null
} else if (this.lastWrittenAuthJson === null) {
// Why: Orca-launched Codex sessions now use an Orca-owned CODEX_HOME
// even when no managed account is selected. Seed that runtime home
// from the user's current system-default auth once so dev/prod Orca
// terminals stay logged in without mutating ~/.codex on startup.
// Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex.
this.restoreSystemDefaultSnapshot({ detectExternalLogin: false })
} else {
this.persistRuntimeLogoutMarker()
@@ -650,10 +625,7 @@ export class CodexRuntimeHomeService {
this.captureSystemDefaultSnapshot({ force: true })
}
// Why: Codex CLI refreshes expired OAuth tokens in CODEX_HOME/auth.json.
// If we detect the runtime file differs from what Orca last wrote, the CLI
// must have refreshed — so we preserve those tokens back to managed
// storage before overwriting runtime with managed state.
// Why: Codex refreshes OAuth tokens in the runtime auth.json; if it differs from Orca's last write, read those back to managed storage before overwriting.
if (this.lastSyncedAccountId === activeAccount.id) {
if (this.skipNextReadBackForAccountId === activeAccount.id) {
this.skipNextReadBackForAccountId = null
@@ -671,10 +643,7 @@ export class CodexRuntimeHomeService {
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}
// Why: called by CodexAccountService before syncForCurrentSelection() after
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
clearLastWrittenAuthJson(
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
): void {
@@ -737,8 +706,7 @@ export class CodexRuntimeHomeService {
}
return 'rejected'
}
// Why: after app restart, Orca has no last-written baseline. Identity
// alone cannot prove runtime auth is newer than managed storage.
// Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage.
if (
lastWrittenAuthJson === null &&
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
@@ -756,9 +724,7 @@ export class CodexRuntimeHomeService {
}
return 'persisted'
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
// Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
return 'rejected'
}
@@ -834,9 +800,7 @@ export class CodexRuntimeHomeService {
const settings = this.store.getSettings()
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
if (selectedAccountId === null) {
// Why: the system-default account changes outside Orca (login, logout,
// token refresh). Read its real home directly so a cached runtime copy
// cannot stay stale; filesystem probing in the fetcher is asynchronous.
// Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy.
return this.getWslSystemCodexHomePath(target)
}
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
@@ -845,9 +809,7 @@ export class CodexRuntimeHomeService {
this.lastSyncedWslAccountIdByDistro.has(distro) &&
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
) {
// Why: RateLimitService resolves provenance twice per poll. Account
// changes sync explicitly, so repeated resolution must stay path-only
// instead of blocking main on UNC reads and a wsl.exe migration probe.
// Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe.
return cachedRuntimeHomePath
}
}
@@ -940,9 +902,7 @@ export class CodexRuntimeHomeService {
(mirroredSystemDefaultAuth === null &&
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
) {
// Why: WSL runtime homes are per-distro and their in-memory baseline is
// lost on app restart. A same-identity fresher runtime auth is a Codex
// token refresh and should be copied back before we mirror ~/.codex.
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
@@ -1022,8 +982,7 @@ export class CodexRuntimeHomeService {
)
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
// Why: WSL drops bash argv here and login-shell cleanup can turn explicit
// `exit 0` into status 1, so keep this script literal and fall-through.
// Why: WSL drops bash argv, so keep the script literal; login-shell cleanup turns `exit 0` into status 1, so fall through.
execFileSync(
'wsl.exe',
[
@@ -1341,9 +1300,7 @@ export class CodexRuntimeHomeService {
this.migrateLegacySessions(managedHomePath, accountId)
}
// Why: migration is intentionally one-shot. Re-importing every startup
// would keep replaying stale managed-home state back into the shared
// runtime and make it feel nondeterministic.
// Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime.
writeFileAtomically(
this.getMigrationMarkerPath(),
`${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n`
@@ -1451,8 +1408,7 @@ export class CodexRuntimeHomeService {
}
private appendListedFiles(target: string[], source: readonly string[]): void {
// Why: migrating legacy session trees must tolerate directories larger than
// V8's argument limit for spread calls.
// Why: tolerate directories larger than V8's argument limit for spread calls.
for (const filePath of source) {
target.push(filePath)
}
@@ -1469,8 +1425,7 @@ export class CodexRuntimeHomeService {
try {
appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' })
} catch (error) {
// Why: conflict diagnostics are useful, but must not make the one-shot
// migration fail after the session file has already been preserved.
// Why: diagnostics must not fail the one-shot migration after the session file is already preserved.
console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error)
}
}
@@ -1521,17 +1476,13 @@ export class CodexRuntimeHomeService {
systemDefaultAuth === mirroredSystemDefaultAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth)
) {
// Why: system-default Codex now refreshes tokens inside Orca's
// runtime CODEX_HOME. Read that refresh back to ~/.codex so the next
// sync does not overwrite fresh runtime credentials with stale ones.
// Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones.
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
this.lastWrittenAuthJson = runtimeAuth
return
}
// Why: the unmanaged path used to read ~/.codex directly. Mirror later
// external logins/logouts into Orca's runtime home so ordinary Orca
// Codex sessions keep matching the user's current system-default state.
// Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state.
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth)
}
@@ -1552,18 +1503,14 @@ export class CodexRuntimeHomeService {
}
if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) {
// Why: once Orca owns the runtime CODEX_HOME, deleting auth.json there is
// a local logout signal for Orca-launched Codex sessions, not a reason to
// rewrite the user's real ~/.codex snapshot back into place.
// Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot.
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
return
}
if (options.detectExternalLogin) {
// Why: while a managed account is selected, the runtime auth file exists
// with managed credentials. If ~/.codex/auth.json vanished meanwhile,
// switching back must preserve that external system-default logout.
// Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout.
rmSync(runtimeAuthPath, { force: true })
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
@@ -1610,9 +1557,7 @@ export class CodexRuntimeHomeService {
}
private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void {
// Why: when the real ~/.codex auth disappears, Orca should treat that as an
// external logout for unmanaged sessions, even if runtime auth had already
// refreshed inside Orca's CODEX_HOME.
// Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME.
rmSync(runtimeAuthPath, { force: true })
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
@@ -1625,8 +1570,7 @@ export class CodexRuntimeHomeService {
}
private writeRuntimeAuth(contents: string): void {
// Why: auth.json contains sensitive credentials. Restrict to owner-only
// so other users on a shared Linux/macOS machine cannot read it.
// Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it.
this.clearRuntimeLogoutMarker()
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
@@ -1737,9 +1681,7 @@ export class CodexRuntimeHomeService {
) {
return parsed as CodexSystemDefaultSnapshot
}
// Why: pre-PR snapshots wrote raw auth.json contents verbatim. Treat any
// valid JSON object without an authJson wrapper as the legacy format so
// upgraders do not lose their system-default auth on first deselect.
// Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth.
if (
parsed &&
typeof parsed === 'object' &&
@@ -1759,9 +1701,7 @@ export class CodexRuntimeHomeService {
}
}
// Why: the seed config is read over UNC but consumed by Codex inside WSL, so
// relative path-valued settings must anchor to the Linux-side source home; a
// verbatim copy breaks Codex config load (os error 2).
// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2).
export function prepareWslRuntimeSeedConfig(
configContents: string,
sourceHomePath: string
+14 -37
View File
@@ -1,6 +1,4 @@
/* eslint-disable max-lines -- Why: this service intentionally keeps Codex
account lifecycle, path safety, login, and identity parsing in one audited
main-process module so the managed-account boundary stays explicit. */
/* eslint-disable max-lines -- Why: keeps Codex account lifecycle, path safety, login, and identity parsing in one audited main-process module. */
import { randomUUID } from 'node:crypto'
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
@@ -71,8 +69,7 @@ type ResolvedCodexIdentity = {
type CanonicalCodexConfig = {
contents: string
/** Home the config was read from, in the path style Codex sees at runtime
* (Linux-side for WSL); relative path-valued settings resolve against it. */
/** Home the config was read from, in the path style Codex sees (Linux-side for WSL); relative settings resolve against it. */
sourceHomePath: string
sourceHooksPath: string
}
@@ -155,9 +152,7 @@ function loginAuthChanged(
}
export class CodexAccountService {
// Why: account mutations read settings, do async work (login, rate-limit
// refresh), then write settings. Without serialization, overlapping calls
// (e.g. double-click "Add Account") can cause lost updates.
// Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates.
private mutationQueue: Promise<unknown> = Promise.resolve()
constructor(
@@ -250,8 +245,7 @@ export class CodexAccountService {
this.runtimeHome.clearLastWrittenAuthJson(account.id)
this.runtimeHome.syncForCurrentSelection()
// Why: the new account becomes active, so the previous active account is
// now inactive and its last-known usage should be cached for the switcher.
// Why: switching activates the new account, so cache the outgoing account's usage for the switcher.
const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection)
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection)
return this.getSnapshot()
@@ -295,9 +289,7 @@ export class CodexAccountService {
this.runtimeHome.clearLastWrittenAuthJson(accountId)
this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account))
// Why: re-auth can change which actual Codex identity the managed home
// points at. Force a fresh read immediately so the status bar cannot keep
// showing the previous account's quota under the updated label.
// Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota.
await this.rateLimits.refreshForCodexAccountChange(
undefined,
getCodexSelectionTargetForAccount(account)
@@ -532,9 +524,7 @@ export class CodexAccountService {
const managedHomePath = join(this.getManagedAccountsRoot(), accountId, 'home')
mkdirSync(managedHomePath, { recursive: true })
// Why: Codex expects CODEX_HOME to be a concrete directory it can own. We
// pre-create the directory and leave a marker so future cleanup code can
// prove the path belongs to Orca before deleting anything.
// Why: marker lets future cleanup prove the path belongs to Orca before deleting anything.
writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8')
return {
managedHomePath: this.assertManagedHomePath(managedHomePath, accountId),
@@ -757,8 +747,7 @@ export class CodexAccountService {
return
}
} catch {
// Why: read errors should not make a stale config look current; the
// atomic write path owns Windows ACL repair and persistent error surfacing.
// Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing.
}
writeFileAtomically(configPath, contents)
}
@@ -795,8 +784,7 @@ export class CodexAccountService {
throw originalError
}
// Why: explicit re-auth is allowed to recover from a lost empty container,
// but only at the exact Orca-owned account path persisted for this account.
// Why: re-auth may recreate a lost empty home, but only at the exact Orca-owned path persisted for this account.
mkdirSync(expectedManagedHomePath, { recursive: true })
writeFileSync(join(expectedManagedHomePath, '.orca-managed-home'), `${account.id}\n`, 'utf-8')
return this.assertManagedHomePath(expectedManagedHomePath, account.id)
@@ -948,8 +936,7 @@ export class CodexAccountService {
linuxHomePath: string,
expectedAccountId: string
): void {
// Why: WSL home creation can fail after mkdir/marker write but before the
// path is trusted. Cleanup must prove the marker/account ID inside WSL.
// Why: creation can fail after mkdir/marker but before trust, so cleanup must verify the marker/account ID inside WSL.
try {
execFileSync(
'wsl.exe',
@@ -1012,13 +999,10 @@ export class CodexAccountService {
return
}
// Why: managed homes live at <accounts-root>/<uuid>/home. Removing
// just the home/ leaf leaves an empty <uuid>/ directory behind.
// Why: homes live at <accounts-root>/<uuid>/home; removing the home/ leaf leaves an empty <uuid>/ behind.
try {
const parentDir = resolve(managedHomePath, '..')
// Why: managedHomePath is already canonicalized by assertManagedHomePath,
// so the root must be canonicalized too for the prefix check to work on
// macOS where userData resolves through /private/var.
// Why: canonicalize the root too so the prefix check works on macOS where userData resolves through /private/var.
const root = realpathSync(this.getManagedAccountsRoot())
if (parentDir.startsWith(root + sep) && parentDir !== root) {
removeManagedHomeTreeSync(parentDir)
@@ -1050,11 +1034,7 @@ export class CodexAccountService {
}
: (() => {
const codexCommand = resolveCodexCommand()
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly without shell:true, but shell:true with an args
// array causes DEP0190 because args are concatenated, not escaped.
// Fix: detect batch scripts and invoke cmd.exe /c explicitly.
// Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly.
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login'])
return {
command: spawnCmd,
@@ -1068,8 +1048,7 @@ export class CodexAccountService {
})()
const child = spawn(spawnConfig.command, spawnConfig.args, {
stdio: ['ignore', 'pipe', 'pipe'],
// Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise
// flash a console window in the packaged GUI app on Windows.
// Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows.
windowsHide: true,
env: spawnConfig.env
})
@@ -1147,9 +1126,7 @@ export class CodexAccountService {
const onError = (error: Error): void => {
settle(() => {
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'
// Why: ENOENT can mean either the codex binary doesn't exist OR the
// script's shebang interpreter (node) isn't in PATH. When we resolved
// codex to a full path, ENOENT almost certainly means node is missing.
// Why: ENOENT is ambiguous — missing codex binary or missing node in PATH; a resolved full path implies node is missing.
const isBareCommand = spawnConfig.codexCommand === 'codex'
const message = isEnoent
? isBareCommand
+21 -56
View File
@@ -19,16 +19,9 @@ import {
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: the config mirror rewrites the runtime config.toml from ~/.codex on
// every launch (and on background rate-limit fetches), so settings the user
// changes inside Orca-launched Codex silently revert. Promotion diffs the
// runtime file against a baseline of what Orca last wrote — anything that
// differs is a change Codex persisted for the user and belongs in ~/.codex.
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
// Why: only the user-preference scalars the Codex TUI itself persists
// (/model writes model + model_reasoning_effort, /approvals writes
// approval_policy + sandbox_mode). Every key added here gets written into the
// user's real ~/.codex/config.toml, so grow this list deliberately.
// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately.
export const PROMOTED_CODEX_SETTING_KEYS = [
'model',
'model_reasoning_effort',
@@ -38,8 +31,7 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
type TopLevelSettingValue = {
raw: string
// Why: a value that opens a multiline string/array cannot be replaced or
// copied line-by-line safely, so it is excluded from promotion entirely.
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
multiline: boolean
}
@@ -78,9 +70,7 @@ function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | nu
}
}
// Why: only keys in the top-level preamble are scanned — Codex writes profile
// overrides into [profiles.*] tables, and rewriting nested tables surgically
// is not worth the risk for stage-1 promotion.
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
@@ -108,19 +98,16 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
}
/**
* Records the promotable top-level settings the runtime config.toml holds
* after a mirror, so the next promotion can tell "value Orca mirrored" apart
* from "value Codex wrote for the user". Call after a successful mirror only —
* advancing the baseline past an unpromoted change would strand it forever.
* Records the promotable settings the runtime config.toml holds after a mirror, so the next
* promotion can tell "value Orca mirrored" from "value Codex wrote for the user".
* Call after a successful mirror only — advancing past an unpromoted change strands it forever.
*/
export function snapshotCodexRuntimeSettingsBaseline(
runtimeHomePath = getOrcaManagedCodexHomePath()
): void {
try {
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: a missing runtime config still records an empty baseline — when
// Codex later creates the file for a user with no ~/.codex/config.toml,
// that first change must diff against "Orca left nothing" and promote.
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
@@ -130,8 +117,7 @@ export function snapshotCodexRuntimeSettingsBaseline(
const file: SettingsBaselineFile = { version: 1, settings }
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
const serialized = `${JSON.stringify(file, null, 2)}\n`
// Why: launch preparation can run repeatedly; skip byte-identical rewrites
// so an unchanged pass does no disk writes.
// Why: launch prep runs repeatedly; skip byte-identical rewrites to avoid needless disk writes.
if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) {
return
}
@@ -157,19 +143,16 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes {
}
/**
* Promotes setting changes the user made inside Orca-launched Codex (written
* by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs
* before the config mirror so the promoted values survive the same mirror
* pass instead of reverting. WSL callers pass explicit per-distro homes; the
* default is the host runtime home and host ~/.codex.
* Promotes in-Codex setting changes from the runtime config.toml into ~/.codex/config.toml.
* Runs before the config mirror so promoted values survive it instead of reverting.
* WSL callers pass explicit per-distro homes; default is the host runtime home and ~/.codex.
*/
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
try {
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
return true
} catch (error) {
// Why: promotion is best-effort launch prep; callers preserve the runtime
// for retry, while a malformed file must not block Codex launch itself.
// Why: promotion is best-effort launch prep; a malformed file must not block Codex launch.
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
return false
}
@@ -185,11 +168,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (!existsSync(runtimeTomlPath)) {
return
}
// Why: without a baseline of what Orca last mirrored (first launch after
// upgrading to a build with promotion, or a corrupted snapshot), a stale
// runtime value is indistinguishable from a fresh in-Codex change. Skip
// this pass — the mirror writes the first baseline and promotion starts on
// the next one.
// Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one.
const baseline = readSettingsBaseline(runtimeHomePath)
if (!baseline) {
return
@@ -210,8 +189,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (system?.multiline) {
continue
}
// Why: ~/.codex stays source of truth — if the user also edited it there
// since the baseline, the outside edit wins over the in-Codex change.
// Why: ~/.codex is source of truth — an outside edit since the baseline wins over the in-Codex change.
if (system?.raw !== baseline.get(key)) {
continue
}
@@ -220,23 +198,16 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (updates.size === 0) {
return
}
// Why: a genuinely fresh host has no ~/.codex yet; without the directory
// the atomic write ENOENTs and the following mirror wipes the setting.
// Owner-only: the directory holds auth.json and the full user config.
// Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it.
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
const writeTarget = resolvePromotionWriteTarget(systemTomlPath)
// Why: a dangling dotfile-manager symlink can point into a directory tree
// that has not been materialized yet; preserve the link and create its real
// parent so the atomic temp file can be written beside the target.
// Why: a dangling symlink may target an unmade dir tree; create its real parent so the atomic temp write has a home.
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: symlink metadata through the \\wsl$ 9P provider is not reliable
// enough for realpath/lstat detection, and an atomic rename would replace
// a WSL-side dotfile symlink with a plain file. Writing through the
// existing file preserves the Linux-side inode (and its mode).
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
writeFileSync(writeTarget.path, nextContent, 'utf-8')
return
}
@@ -245,11 +216,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
})
}
// Why: promotion rewrites the user's real config.toml. Follow an existing
// symlink (dotfile managers) instead of replacing the link with a plain file,
// and carry the real file's mode forward — an atomic write without a mode
// would widen a user-restricted 0600 config to the process umask default.
// A new or unreadable target is created owner-only.
// Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config.
function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } {
try {
const realPath = realpathSync(systemTomlPath)
@@ -282,8 +249,7 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
return currentPath
}
}
// Why: replacing any link in a cycle would destroy dotfile-manager state;
// abort promotion and leave the runtime/baseline intact for manual repair.
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
@@ -310,8 +276,7 @@ export function upsertTopLevelSettingsInContent(
state = updateTomlLineScanState(state, line)
}
// Why: CRLF configs keep a trailing \r after the split; new lines must use
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
+36 -94
View File
@@ -35,10 +35,7 @@ import {
type CodexTrustEntry
} from './config-toml-trust'
// Why: this hash was captured from a real Codex 0.129 `/hooks` approval. If
// Codex changes its serialization or normalization rules, this test fails
// loudly instead of silently shipping bad trust entries that put hooks back
// into the review pile.
// Why: captured from a real Codex 0.129 `/hooks` approval; fails loudly if Codex's serialization drifts.
const REAL_APPROVED_COMMAND = '/bin/sh "/tmp/orca-case-b-mCmCe6/agent-hooks/codex-hook.sh"'
const REAL_APPROVED_HASH = 'sha256:bc013489dba495431d3790fda62ee5a7d907a7c491e29ad26238c3a5d6d2b163'
@@ -160,12 +157,7 @@ describe('computeTrustedHash', () => {
})
it('drops the matcher on user_prompt_submit/stop like matcher_pattern_for_event', () => {
// Why: Codex ignores matchers on these two events and hashes the
// identity WITHOUT the matcher field. A definition carrying
// `"matcher": ""` (common in third-party installers) must therefore
// hash identically to one without a matcher, or the system-trust
// mirror misses and the stale-entry sweep deletes the trust Codex
// wrote — re-prompting the user on every launch.
// Why: Codex hashes these two events WITHOUT the matcher, so `"matcher": ""` must hash like no matcher.
for (const eventLabel of ['user_prompt_submit', 'stop'] as const) {
const base: CodexTrustEntry = {
sourcePath: '/x/hooks.json',
@@ -181,12 +173,7 @@ describe('computeTrustedHash', () => {
})
it('pins the matcher-omitted hash for a Stop entry that carries an empty matcher', () => {
// Why: regression pin for the shape observed in the wild (a real
// Codex 0.140 config.toml, path anonymized): Codex stores the
// matcher-omitted hash for Stop even when hooks.json carries
// `"matcher": ""`, so we must never fold the matcher into this
// identity. If serialization or normalization drifts, this constant
// fails loudly.
// Why: regression pin (real Codex 0.140 config) — Stop uses the matcher-omitted hash even with `"matcher": ""`.
expect(
computeTrustedHash({
sourcePath: '/home/user/.codex/hooks.json',
@@ -329,8 +316,7 @@ describe('computeTrustKey', () => {
)
it('uses native Windows backslashes in the trust key Codex looks up', () => {
// Why: Codex 0.140 writes approved Windows hook trust keys as raw native
// paths under [hooks.state].
// Why: Codex 0.140 writes approved Windows hook trust keys as raw native paths under [hooks.state].
const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const key = computeTrustKey({
sourcePath: winPath,
@@ -442,8 +428,7 @@ describe('upsertHookTrustEntries', () => {
expect(written).not.toContain('STALE')
expect(written).toContain('[unrelated]')
expect(written).toContain('value = 42')
// Why: we only own the [hooks.state."<key>"] block — the [features]
// block must be unchanged.
// Why: we only own the [hooks.state."<key>"] block — [features] must be untouched.
expect(written).toContain('[features]\nhooks = true')
})
@@ -586,8 +571,7 @@ describe('upsertHookTrustEntries', () => {
}
upsertHookTrustEntries(configPath, [entry])
const firstWrite = readFileSync(configPath, 'utf-8')
// Why: a no-op upsert must not roll the .bak forward — repeated calls
// (e.g. from app start) would otherwise destroy the last recoverable copy.
// Why: a no-op upsert must not roll .bak forward, or repeated calls destroy the last recoverable copy.
rmSync(`${configPath}.bak`, { force: true })
upsertHookTrustEntries(configPath, [entry])
expect(existsSync(`${configPath}.bak`)).toBe(false)
@@ -595,8 +579,7 @@ describe('upsertHookTrustEntries', () => {
})
it('replaces a stale block written with CRLF line endings without duplicating', () => {
// Why: regression — Windows-style \r\n in the existing config previously
// caused the header pattern to miss and append a duplicate block.
// Why: regression — \r\n in the existing config made the header pattern miss and append a duplicate.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'[features]',
@@ -677,8 +660,7 @@ describe('upsertHookTrustEntries', () => {
expect(written).toContain('foo = 1')
})
// Why: TOML supports both basic-string and literal-string quoted keys;
// header detection must respect `]` inside `'...'` too.
// Why: TOML allows literal-string quoted keys, so header detection must respect `]` inside `'...'`.
it('preserves an unrelated table whose literal-string key contains a `]`', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
@@ -828,8 +810,7 @@ describe('upsertHookTrustEntries', () => {
})
it('treats `\\"""` inside a multi-line basic string as an escaped quote, not a close', () => {
// Why: a basic multi-line string with `\"` escapes must not be misread as
// closing early — content and any following real header must survive intact.
// Why: `\"` escapes in a multi-line basic string must not be misread as closing early.
const original = [
'prompt = """',
'use \\"\\"\\" carefully',
@@ -858,8 +839,7 @@ describe('upsertHookTrustEntries', () => {
})
it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => {
// Why: a backslash in a POSIX path can be a literal filename character, so
// it must still be escaped instead of normalized away.
// Why: a backslash in a POSIX path is a literal filename char, so escape it instead of normalizing.
const entry: CodexTrustEntry = {
sourcePath: '/x/with"quote\\and\\back/hooks.json',
eventLabel: 'pre_tool_use',
@@ -876,9 +856,7 @@ describe('upsertHookTrustEntries', () => {
})
it('overwrites an existing block whose header has leading whitespace (TOML allows indent)', () => {
// Why: regression — buildHeaderPattern used to require column-0 headers,
// but the reader accepts indented ones. That mismatch caused upsert to
// append a duplicate `[hooks.state."<key>"]` block, producing invalid TOML.
// Why: regression — buildHeaderPattern required column-0 headers but the reader accepts indented ones.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = ` [hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
@@ -900,9 +878,7 @@ describe('upsertHookTrustEntries', () => {
})
it('preserves `enabled = false` when the user hand-edited it before reinstall', () => {
// Why: regression — auto-install on app start used to clobber a
// hand-disabled hook back to enabled = true, removing the only way to
// mute Orca's hook short of full uninstall.
// Why: regression — auto-install used to clobber a hand-disabled hook back to enabled = true.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"]\nenabled = false\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
@@ -923,9 +899,7 @@ describe('upsertHookTrustEntries', () => {
})
it('overwrites an existing block when the file ends without a trailing newline', () => {
// Why: regression — buildHeaderPattern used to require a trailing
// `\r?\n`, missing it caused the upsert path to take the no-match branch
// and append a duplicate block at EOF.
// Why: regression — buildHeaderPattern required a trailing newline, appending a duplicate at EOF.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"]\nenabled = true\ntrusted_hash = "sha256:OLD"`
writeFileSync(configPath, original, 'utf-8')
@@ -947,10 +921,7 @@ describe('upsertHookTrustEntries', () => {
})
it('overwrites an existing block whose header has an inline comment', () => {
// Why: regression — buildHeaderPattern used to require the header line
// to end at \r?\n or EOF, missing TOML-valid trailing comments. The
// upsert path then took the no-match branch and appended a duplicate
// `[hooks.state."<key>"]` block, producing invalid TOML.
// Why: regression — buildHeaderPattern missed TOML-valid trailing comments and appended a duplicate block.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:OLD"\n`
writeFileSync(configPath, original, 'utf-8')
@@ -972,8 +943,7 @@ describe('upsertHookTrustEntries', () => {
})
it('finds and replaces a legacy forward-slash block when Orca upserts with native backslash key', () => {
// Why: Codex 0.140 can expose Windows trust keys with either separator
// shape depending on startup cwd, so Orca replaces stale blocks with both.
// Why: Codex 0.140 exposes Windows keys with either separator depending on cwd, so replace both.
const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const legacyKey = `${backslashPath.replace(/\\/g, '/')}:session_start:0:0`
const original = [
@@ -1002,8 +972,7 @@ describe('upsertHookTrustEntries', () => {
})
it('produces exactly one Windows separator pair after two consecutive upserts', () => {
// Why: idempotency guard — repeated auto-install on app start must not
// accumulate duplicate trust blocks and produce invalid TOML.
// Why: idempotency guard — repeated auto-install must not accumulate duplicate blocks.
const entry: CodexTrustEntry = {
sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json',
eventLabel: 'session_start',
@@ -1020,8 +989,7 @@ describe('upsertHookTrustEntries', () => {
})
it('falls back to TOML basic-string headers when a Windows path contains an apostrophe', () => {
// Why: TOML literal-string table keys cannot contain apostrophes, but
// Windows user/profile paths can.
// Why: TOML literal-string keys can't hold apostrophes, but Windows profile paths can.
const entry: CodexTrustEntry = {
sourcePath: "C:\\Users\\O'Connor\\AppData\\Roaming\\orca\\hooks.json",
eventLabel: 'session_start',
@@ -1043,10 +1011,7 @@ describe('upsertHookTrustEntries', () => {
})
it('finds a Codex-written block with lowercased username when Orca key has mixed-case username', () => {
// Why: realpathSync.native casing can differ between what Codex wrote
// (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...).
// normalizeHookTrustKeyForLookup case-folds Windows-shaped paths so the
// existing block is replaced rather than a duplicate appended.
// Why: realpathSync.native casing can differ from what Codex wrote, so case-fold to replace not duplicate.
const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json'
const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
const literalKey = `${lowercasePath}:session_start:0:0`
@@ -1136,8 +1101,7 @@ describe('upsertProjectTrustLevel', () => {
})
it('preserves CRLF endings and writes native Windows path separators in the header', () => {
// Why: local project trust still follows Codex's realpath display, while
// remote project trust preserves the SSH provider's canonical path string.
// Why: local trust follows Codex's realpath; remote trust preserves the SSH provider's canonical path.
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
@@ -1149,8 +1113,7 @@ describe('upsertProjectTrustLevel', () => {
})
it('updates an existing Windows backslash project block after separator normalization', () => {
// Why: hook trust now writes paired Windows variants, but project trust
// must still repair an existing single project table in place.
// Why: hook trust writes paired Windows variants, but project trust still repairs a single table in place.
const original = [
'[projects."C:\\\\Users\\\\nw\\\\repo"]',
'notes = "keep"',
@@ -1168,8 +1131,7 @@ describe('upsertProjectTrustLevel', () => {
})
it('updates an existing legacy Windows forward-slash project block', () => {
// Why: older Orca builds normalized Windows project paths to forward
// slashes; native-backslash hook fixes must not duplicate those blocks.
// Why: older Orca builds normalized to forward slashes; backslash fixes must not duplicate them.
const original = [
'[projects."C:/Users/nw/repo"]',
'notes = "keep"',
@@ -1228,8 +1190,7 @@ describe('upsertProjectTrustLevel', () => {
})
it('keeps case-distinct WSL Linux project paths as separate trust blocks', () => {
// Why: the \\wsl$\<distro> share is case-insensitive on Windows, but the
// Linux path underneath is not — .../Repo and .../repo are two projects.
// Why: \\wsl$\<distro> is case-insensitive but the Linux path under it is not — two distinct projects.
const existingPath = '\\\\wsl$\\Ubuntu\\home\\u\\Repo'
const incomingPath = '\\\\wsl$\\Ubuntu\\home\\u\\repo'
const original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n')
@@ -1240,16 +1201,14 @@ describe('upsertProjectTrustLevel', () => {
expect(updated.match(/\[projects\./g)).toHaveLength(2)
expect(updated).toContain(`[projects.'${existingPath}']`)
// Why: serializer writes basic-string headers via escapeTomlString; assert
// that form so the fixture can't drift from real header matching.
// Why: serializer writes basic-string headers via escapeTomlString; assert that exact form.
expect(updated).toContain(`[projects."${escapeTomlString(incomingPath)}"]`)
expect(updated).toContain('trust_level = "untrusted"')
expect(updated).toContain('trust_level = "trusted"')
})
it('updates the same WSL project block across wsl$ and wsl.localhost spellings', () => {
// Why: the two share spellings alias the same distro filesystem, so a
// revoked project must not be re-trusted under the other spelling.
// Why: the two share spellings alias the same distro, so a revoke must not survive under the other.
const original = [
"[projects.'\\\\wsl$\\Ubuntu\\home\\u\\proj']",
'trust_level = "untrusted"',
@@ -1283,8 +1242,7 @@ describe('upsertProjectTrustLevel', () => {
})
it('preserves an already-canonical remote Windows project path', () => {
// Why: SSH project paths are resolved on the remote; local realpath would
// canonicalize the wrong machine if the same path happens to exist locally.
// Why: SSH paths resolve on the remote; local realpath would canonicalize the wrong machine.
const updated = upsertProjectTrustLevelInContent('', 'C:/Users/nw/repo', 'trusted', {
alreadyCanonical: true
})
@@ -1320,8 +1278,7 @@ describe('normalizeCodexProjectPathForLookup', () => {
})
it('merges separator and distro-casing variants of the same WSL path', () => {
// Why: separators and the case-insensitive \\wsl$\<distro> share may drift,
// but the same Linux path must still resolve to one trust key.
// Why: separator and \\wsl$\<distro> casing may drift, but the same Linux path is one trust key.
expect(normalizeCodexProjectPathForLookup('\\\\wsl$\\Ubuntu\\home\\u\\proj')).toBe(
normalizeCodexProjectPathForLookup('//WSL$/ubuntu/home/u/proj')
)
@@ -1587,9 +1544,7 @@ describe('removeHookTrustEntries', () => {
})
it('preserves the line separator when no blank line precedes the removed block', () => {
// Why: regression — removeTrustBlock used to cut from match.index (the
// captured leading newline) and fused the previous content into the next
// header, producing invalid TOML like `a = 1[other]`.
// Why: regression — removeTrustBlock cut the leading newline, fusing prior content into the next header.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
'a = 1',
@@ -1634,8 +1589,7 @@ describe('removeHookTrustEntries', () => {
})
it('removes a block whose header has an inline comment', () => {
// Why: paired with the upsert regression; the same pattern mismatch
// would silently leave the dead block in place during uninstall.
// Why: same pattern mismatch as the upsert regression would leave the dead block during uninstall.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:K"\n`
writeFileSync(configPath, original, 'utf-8')
@@ -1774,10 +1728,7 @@ describe('readHookTrustEntries', () => {
})
it('normalizes backslash block key to forward-slash at ingestion', () => {
// Why: a real Windows path on disk like C:\foo gets written escaped as
// `C:\\foo` inside the TOML key. The Map key is normalized (backslash ->
// forward-slash) so computeTrustKey lookups match regardless of how Codex
// encoded the path separator.
// Why: normalize the Map key (backslash -> forward-slash) so computeTrustKey lookups match either encoding.
const original = [
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
'enabled = true',
@@ -1808,8 +1759,7 @@ describe('readHookTrustEntries', () => {
})
it('supports case-insensitive lookups for Windows hook trust keys read from config', () => {
// Why: Codex and realpathSync.native can disagree on user-path casing;
// status checks still need Map.get(computeTrustKey(...)) to find the row.
// Why: Codex and realpathSync.native can disagree on path casing, but lookups must still match.
const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0'
const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0'
const original = [
@@ -1851,8 +1801,7 @@ describe('readHookTrustEntries', () => {
})
it('keeps case-distinct WSL UNC hook paths distinct', () => {
// Why: the \\wsl$\<distro> share is case-insensitive, but the Linux tail
// is not — folding the whole path would merge two distinct hook sources.
// Why: \\wsl$\<distro> is case-insensitive but the Linux tail is not — don't fold two distinct sources.
const upperKey = '\\\\wsl$\\Ubuntu\\home\\u\\Repo\\hooks.json:session_start:0:0'
const lowerKey = '\\\\wsl$\\Ubuntu\\home\\u\\repo\\hooks.json:session_start:0:0'
writeFileSync(
@@ -1920,8 +1869,7 @@ describe('readHookTrustEntries', () => {
})
it('does not extract a fake [hooks.state."<key>"] header from inside a """ block', () => {
// Why: a header-shaped line embedded in a multi-line basic string must not
// be parsed as a real trust entry.
// Why: a header-shaped line inside a multi-line basic string must not parse as a real entry.
const original = [
'description = """',
'[hooks.state."fake-key"]',
@@ -1953,9 +1901,7 @@ describe('readHookTrustEntries', () => {
})
it('reads a block whose header has an inline comment', () => {
// Why: regression — headerLineRegex used to reject TOML-valid trailing
// comments, hiding existing trust entries from getStatus and causing
// it to misreport hooks as untrusted.
// Why: regression — headerLineRegex rejected TOML-valid trailing comments, hiding trust entries.
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = `[hooks.state."${key}"] # user note\nenabled = true\ntrusted_hash = "sha256:CMT"\n`
writeFileSync(configPath, original, 'utf-8')
@@ -1977,8 +1923,7 @@ describe('parseTrustKey', () => {
})
it('parses a Windows-style sourcePath whose drive letter contains a colon', () => {
// Why: validates the "anchor on the LAST three colons" approach so colons
// inside the sourcePath itself round-trip correctly.
// Why: anchor on the LAST three colons so colons inside sourcePath round-trip.
expect(parseTrustKey('C:\\Users\\x\\.codex\\hooks.json:session_start:2:3')).toEqual({
sourcePath: 'C:\\Users\\x\\.codex\\hooks.json',
eventLabel: 'session_start',
@@ -2025,8 +1970,7 @@ describe('parseTrustKey', () => {
})
})
// Why: Number('') === 0 silently passes Number.isInteger; without strict
// canonical-form validation, malformed keys would coerce into valid ones.
// Why: Number('') === 0 passes Number.isInteger, so empty segments need explicit rejection.
it('returns null for empty group/handler segments', () => {
expect(parseTrustKey('/x/hooks.json:pre_tool_use::0')).toBeNull()
expect(parseTrustKey('/x/hooks.json:pre_tool_use:0:')).toBeNull()
@@ -2041,9 +1985,7 @@ describe('parseTrustKey', () => {
})
describe('upsertHookTrustEntries with array-of-tables boundaries', () => {
// Why: findNextTableHeader must treat `[[array.of.tables]]` as a block
// boundary; otherwise an upsert/remove can consume past array entries
// into unrelated user content.
// Why: [[array.of.tables]] must count as a block boundary, else upsert/remove eats past array entries.
it('stops the replacement at a following [[array.of.tables]] header', () => {
const key = '/x/hooks.json:pre_tool_use:0:0'
const original = [
+31 -100
View File
@@ -20,13 +20,8 @@ import {
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
// the "review required" pile and never fires, so the agent-status sidebar
// silently goes blank. We reproduce Codex's hash so install() can register
// trust the same way `/hooks` would. Algorithm reverse-engineered from
// codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) +
// codex-rs/config/src/fingerprint.rs (version_for_toml).
// Why: Codex 0.129+ gates each hook on a `trusted_hash` in config.toml under [hooks.state."<key>"]; without it the hook never fires (agent-status goes blank).
// Hash algorithm reverse-engineered from codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) + config/src/fingerprint.rs (version_for_toml).
export type CodexEventLabel =
| 'pre_tool_use'
@@ -49,8 +44,7 @@ export type CodexTrustEntry = {
handlerIndex: number
/** The exact `command` string written to hooks.json. */
command: string
/** Effective timeout in seconds. When undefined, defaults to 600.
* Explicit values are clamped to a minimum of 1. */
/** Effective timeout in seconds; defaults to 600 when undefined, explicit values clamped to a minimum of 1. */
timeoutSec?: number
/** Whether the handler is async. Defaults to false. */
async?: boolean
@@ -58,12 +52,9 @@ export type CodexTrustEntry = {
matcher?: string
/** Optional statusMessage field. */
statusMessage?: string
/** Verbatim hash to write instead of computing one. Used when carrying a
* Codex-written approval across files, so trust survives even if Codex's
* hash algorithm drifts from computeTrustedHash. Never fed into hashing. */
/** Verbatim hash to write instead of computing one (survives Codex hash-algorithm drift). Never fed into hashing. */
trustedHash?: string
/** Explicit enabled state to write. When absent, a pre-existing
* `enabled = false` on the target block is preserved. */
/** Explicit enabled state to write; when absent, a pre-existing `enabled = false` is preserved. */
enabled?: boolean
}
@@ -74,8 +65,7 @@ export type CodexHookTrustState = {
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
// Why: callers use computeTrustKey() for lookups, while existing TOML can carry
// Codex-written separator/casing variants. Normalize both sides at the Map edge.
// Why: normalize keys at the Map edge so Codex-written separator/casing variants match computeTrustKey() lookups.
class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
override get(key: string): CodexHookTrustState | undefined {
return super.get(normalizeHookTrustKeyForLookup(key))
@@ -94,8 +84,7 @@ class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
}
}
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
// Why: matches Codex's canonical_json — recursively sorts object keys before hashing; arrays keep order.
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize)
@@ -110,14 +99,7 @@ function canonicalize(value: unknown): unknown {
return value
}
// Why: reproduces matcher_pattern_for_event (codex-rs
// hooks/src/events/common.rs). Codex ignores matchers on
// UserPromptSubmit/Stop and drops them from the hook identity BEFORE
// hashing, so a hooks.json entry carrying `"matcher": ""` on those
// events must hash the same as one without it. Including it computes a
// hash Codex never writes: system trust then looks stale, and
// removeStaleRuntimeHookTrustEntries deletes the entry Codex wrote on
// every launch — an endless re-trust prompt for those two events.
// Why: mirrors codex-rs matcher_pattern_for_event (hooks/src/events/common.rs) — Codex drops matchers on user_prompt_submit/stop before hashing, so including one yields a hash it never writes → endless re-trust.
function matcherPatternForEvent(
eventLabel: CodexEventLabel,
matcher: string | undefined
@@ -136,12 +118,7 @@ function matcherPatternForEvent(
}
}
// Why: reproduces command_hook_hash. NormalizedHookIdentity has `group:
// MatcherGroup` flattened in, so the wire shape is { event_name, matcher?,
// hooks: [<normalized handler>] }. `matcher` is omitted (not null) when
// absent — Rust's Option<String>=None drops through the TOML→JSON path —
// and normalized per event first (see matcherPatternForEvent).
// Handler is normalized to timeout=600 (or explicit, min 1) and async=false.
// Why: reproduces Codex's command_hook_hash; wire shape is { event_name, matcher?, hooks:[handler] } with matcher omitted (not null) when absent.
export function computeTrustedHash(entry: CodexTrustEntry): string {
const handler: Record<string, unknown> = {
type: 'command',
@@ -224,8 +201,7 @@ function stripWindowsDevicePrefix(sourcePath: string): string {
function getCodexCanonicalProjectPath(projectPath: string): string {
try {
// Why: local project trust still needs Codex's realpath shape, but remote
// SSH callers pass an already-canonical remote path string.
// Why: local trust needs Codex's realpath shape, but remote SSH callers already pass a canonical path.
return realpathSync.native(projectPath)
} catch {
return projectPath
@@ -255,14 +231,12 @@ function isWindowsPathForTrustSource(sourcePath: string): boolean {
)
}
// Why: Codex and Orca can disagree on quote style, separators, and casing for
// the same Windows project, including when the caller targets a remote host.
// Why: Codex and Orca can disagree on quote style, separators, and casing for the same Windows project.
export function normalizeCodexProjectPathForLookup(projectPath: string): string {
if (!usesWindowsPathSeparators(projectPath)) {
return projectPath
}
// Why: the Linux path under a WSL share is case-sensitive, so folding it would
// conflate distinct dirs (e.g. .../Repo vs .../repo) onto one trust key.
// Why: the Linux tail under a WSL share is case-sensitive, so folding it would conflate distinct dirs onto one key.
const slashedPath = normalizeWindowsPathSeparators(projectPath)
return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase()
}
@@ -290,9 +264,7 @@ export function parseTrustKey(key: string): {
groupIndex: number
handlerIndex: number
} | null {
// Why: keys have shape `<sourcePath>:<eventLabel>:<groupIdx>:<handlerIdx>`.
// sourcePath itself may contain `:` (Windows drive letters), so anchor the
// parse at the LAST three colons rather than the first.
// Why: sourcePath may contain `:` (Windows drive letters), so anchor the parse at the last three colons.
const lastColon = key.lastIndexOf(':')
if (lastColon === -1) {
return null
@@ -329,8 +301,7 @@ export function parseTrustKey(key: string): {
}
}
// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger,
// so reject any non-canonical decimal form before numeric conversion.
// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger, so reject non-canonical decimal forms first.
function isCanonicalNonNegativeInt(value: string): boolean {
return /^(0|[1-9]\d*)$/.test(value)
}
@@ -348,25 +319,14 @@ function isCodexEventLabel(value: string): value is CodexEventLabel {
)
}
// Why: TOML 1.0 forbids BOMs but real-world editors (especially on Windows) sometimes
// write them. A leading  would break header regexes anchored at `^[ \t]*\[`, so
// strip it once at the file boundary and let the rest of the parser stay simple.
// Why: strip a leading BOM (some Windows editors write one) so header regexes anchored at `^[ \t]*\[` still match.
function readTomlFile(configPath: string): string {
const raw = readFileSync(configPath, 'utf-8')
return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
}
// Why: regex-edit ~/.codex/config.toml rather than parse + reserialize. The
// file is hand-edited by users (and other tools) and a round-trip through
// any TOML library would lose comments, key ordering, and inline-table
// style. We only ever (a) replace an existing [hooks.state."<key>"] block
// keyed by *our* known hook keys, or (b) append a new block at EOF. Other
// content is byte-preserved.
// Why: this is a read-modify-write with no inter-process lock. Codex CLI's
// /hooks flow also writes [hooks.state.*] blocks in this file, so two
// concurrent writers can lose each other's edits. writeConfigAtomically
// prevents partial writes but not lost updates. install() is idempotent
// (deterministic hashes), so the next install() repairs drift.
// Why: regex-edit config.toml (not parse+reserialize) to byte-preserve user comments, key ordering, and inline-table style.
// Why: this read-modify-write has no lock and races Codex's /hooks writer, but idempotent install() repairs any lost update.
export function upsertHookTrustEntries(
configPath: string,
entries: readonly CodexTrustEntry[]
@@ -458,10 +418,7 @@ export function upsertProjectTrustLevelInContent(
return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}`
}
// Why: build the canonical block we own. The two field names mirror what
// Codex itself writes when the user approves via /hooks (HookStateToml
// fields). `enabled` is plumbed through so an existing user-set
// `enabled = false` survives reinstall.
// Why: field names mirror Codex's HookStateToml (/hooks approval); `enabled` is plumbed so a user-set `enabled = false` survives reinstall.
function buildTrustBlock(key: string, hash: string, enabled: boolean): string {
return [
`[hooks.state.${formatHookStateTableKey(key)}]`,
@@ -473,8 +430,7 @@ function buildTrustBlock(key: string, hash: string, enabled: boolean): string {
function formatHookStateTableKey(key: string): string {
const parsed = parseTrustKey(key)
if (parsed && usesWindowsPathSeparators(parsed.sourcePath) && !key.includes("'")) {
// Why: Codex 0.140 trusts Windows hooks only when the state table keys
// match the raw native path shape it writes after /hooks approval.
// Why: Codex 0.140 trusts Windows hooks only when state table keys match the raw native path shape it writes.
return `'${key}'`
}
return `"${escapeTomlString(key)}"`
@@ -492,8 +448,7 @@ function getTrustKeyWriteVariants(key: string): string[] {
].filter((variant, index, variants) => variants.indexOf(variant) === index)
}
// Why: TOML basic strings forbid raw control chars; escape backslash first so
// later substitutions don't double-escape the inserted backslashes.
// Why: escape backslash first so later substitutions don't double-escape the inserted backslashes.
export function escapeTomlString(value: string): string {
return value
.replaceAll('\\', '\\\\')
@@ -525,19 +480,12 @@ function upsertTrustBlocks(
if (content.length === 0) {
return `${block}\n`
}
// Why: leave one blank line before our appended block so the file stays
// readable, but don't compound separators when the file already ends in
// a blank line.
// Why: one blank line before the appended block, without compounding separators when the file already ends blank.
const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n'
return `${content}${separator}${block}\n`
}
// Why: preserve a user-set `enabled = false` so a hand-disabled hook is not
// silently re-enabled by the next auto-install on app start.
// If duplicate blocks already exist, treat any disabled copy as authoritative
// while collapsing the malformed TOML back to one table.
// An explicit enabled state (write-back promotion of an in-Orca /hooks
// toggle) overrides that preservation: it IS the user's latest decision.
// Why: preserve a user-set `enabled = false` (any disabled duplicate wins) unless an explicit state overrides it.
const enabled =
explicitEnabled ??
!ranges.some((range) => {
@@ -561,8 +509,7 @@ function upsertTrustBlocks(
}
function buildTrustBlocks(keys: readonly string[], hash: string, enabled: boolean): string {
// Why: Codex 0.140 can expose Windows hook-state keys with either native
// backslashes or forward slashes depending on the cwd string used at startup.
// Why: Codex 0.140 exposes Windows hook-state keys with either backslashes or forward slashes depending on startup cwd.
return keys.map((key) => buildTrustBlock(key, hash, enabled)).join('\n\n')
}
@@ -589,9 +536,7 @@ type TrustBlockRange = {
end: number
}
// Why: separator and casing drift between Codex-written keys (raw backslash,
// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native
// casing) must not prevent findTrustBlockRanges from matching an existing block.
// Why: separator/casing drift between Codex-written and Orca-built keys must not stop findTrustBlockRanges from matching.
export function normalizeHookTrustKeyForLookup(key: string): string {
const parsed = parseTrustKey(key)
// Why: fold by path shape, not host platform — hook sources on WSL and SSH
@@ -656,8 +601,7 @@ type ParsedTomlString = {
endIndex: number
}
// Why: Codex can write hook-state dotted keys as TOML basic strings while
// users/Codex TUI may leave equivalent literal-string keys behind.
// Why: hook-state keys appear as both TOML basic strings and equivalent literal-string keys.
function parseHookStateHeaderKey(line: string): string | null {
const trimmed = line.trimStart()
const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed)
@@ -677,8 +621,7 @@ function parseHookStateHeaderKey(line: string): string | null {
}
export function parseCodexProjectHeaderPath(line: string): string | null {
// Why: mirror section headers come from split CRLF files and retain the
// terminal carriage return, while direct upserts scan CR-stripped lines.
// Why: mirror section headers retain a terminal CR (split CRLF files) while direct upserts scan CR-stripped lines.
const trimmed = line.replace(/\r$/, '').trimStart()
const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed)
if (!prefixMatch) {
@@ -766,9 +709,7 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number {
}
return index
}
// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines
// inside multi-line strings aren't headers, so we need a stateful scanner —
// a flat regex misclassifies both cases.
// Why: quoted keys can contain `]` and `[` lines inside multi-line strings aren't headers, so a flat regex misclassifies both — need a stateful scan.
function findNextTableHeader(text: string): number {
let cursor = 0
let scanState = createTomlLineScanState()
@@ -779,9 +720,7 @@ function findNextTableHeader(text: string): number {
const line = rawLine.replace(/\r$/, '')
if (isTomlStructuralLine(scanState)) {
const trimmed = line.trimStart()
// Why: stop at both `[table]` and `[[array.of.tables]]` — both end our
// block. Skipping `[[ ]]` here would let our slice consume past array
// entries into unrelated user content.
// Why: stop at both `[table]` and `[[array.of.tables]]`; skipping `[[ ]]` would let the slice consume unrelated content.
if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) {
return cursor
}
@@ -795,10 +734,7 @@ function findNextTableHeader(text: string): number {
return -1
}
// Why: walk the header byte-by-byte so `]` inside a quoted key segment
// doesn't terminate us early. Basic strings honor `\` escapes; literal
// strings (single quotes) don't allow escapes per TOML spec.
// Accepts both `[table]` and `[[array.of.tables]]` since either ends a block.
// Why: walk byte-by-byte so a `]` inside a quoted key segment doesn't terminate the header early.
function isCompleteTableHeader(line: string): boolean {
if (!line.startsWith('[')) {
return false
@@ -853,10 +789,7 @@ function isCompleteTableHeader(line: string): boolean {
return false
}
// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a
// half-written config.toml can brick a user's Codex install, so write to
// tmp and rename. Random-suffix tmp name avoids cross-process races on
// rapid reinstalls.
// Why: a half-written config.toml can brick Codex, so write to a random-suffix tmp then rename (.bak rotation), avoiding cross-process races.
export function writeConfigAtomically(configPath: string, contents: string): void {
let writePath = configPath
let isSymlink = false
@@ -984,7 +917,6 @@ export function readHookTrustEntriesFromContent(content: string): Map<string, Co
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
if (key !== null) {
// Why: block ends at the next *real* header (multi-line aware).
const after = content.slice(nextCursor)
const nextHeaderRel = findNextTableHeader(after)
const blockEnd = nextHeaderRel === -1 ? content.length : nextCursor + nextHeaderRel
@@ -1048,8 +980,7 @@ function unescapeTomlBasicStringEscape(next: string): string {
if (next === '\\') {
return '\\'
}
// Why: unknown escapes round-trip — preserve the backslash so we don't
// silently drop information.
// Why: unknown escapes round-trip — preserve the backslash so info isn't dropped.
return `\\${next}`
}
+47 -125
View File
@@ -82,11 +82,7 @@ import {
import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase'
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
// PermissionRequest is the human-input boundary: the managed script exits
// without a decision so Codex still shows its normal approval UI, while Orca
// can flip the pane to the red waiting state.
// Why: Pre/PostToolUse feed the live in-flight-tool readout; PermissionRequest exits with no decision so Codex still shows its approval UI while Orca flips the pane to waiting.
const CODEX_EVENTS = [
'SessionStart',
'UserPromptSubmit',
@@ -101,8 +97,7 @@ function getConfigPath(runtimeHomePath: string = getOrcaManagedCodexHomePath()):
}
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
// Why: Codex rejects unknown top-level hooks.json fields, so plugin manager
// bookkeeping such as `_managed` must not survive Orca's rewrite.
// Why: Codex rejects unknown top-level hooks.json fields, so plugin bookkeeping like `_managed` must not survive Orca's rewrite.
writeHooksJson(configPath, { hooks })
}
@@ -110,8 +105,7 @@ function getCodexConfigTomlPath(runtimeHomePath: string = getOrcaManagedCodexHom
return join(runtimeHomePath, 'config.toml')
}
// Why: the managed-event subset of the shared PascalCase→label map; the
// full mapping lives in codex-hook-identity.ts so promotion can't drift.
// Why: managed-event subset of the shared label map; full mapping lives in codex-hook-identity.ts so promotion can't drift.
const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = {
SessionStart: CODEX_HOOK_EVENT_LABEL.SessionStart!,
UserPromptSubmit: CODEX_HOOK_EVENT_LABEL.UserPromptSubmit!,
@@ -188,8 +182,7 @@ export type { CodexWslRuntimeHookInstallPlan }
function wrapReadablePosixHookCommand(scriptPath: string): string {
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
// Why: WSL runtime hooks are written from Windows through UNC, where the
// executable bit is not reliable; a missing script must still own stdin.
// Why: WSL hooks are written from Windows over UNC where the exec bit is unreliable; a missing script must still own stdin.
return `if [ -f ${quoted} ] && [ -r ${quoted} ]; then /bin/sh ${quoted}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
}
@@ -290,9 +283,7 @@ function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boo
}
function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): HookDefinition[] {
// Why: Orca mirrors system hooks into a plain runtime hooks.json. Plugin
// placeholders only work for Codex plugin hook sources, so copying those
// commands here strips the environment they require and turns them into 127s.
// Why: plugin placeholders only resolve for Codex plugin hook sources; mirroring them into a plain runtime hooks.json turns them into 127s.
return removeManagedCommands(definitions, commandUsesCodexPluginOnlyPlaceholder)
}
@@ -332,9 +323,7 @@ function getRuntimeHooksWithSystemUserHooks(
continue
}
// Why: runtime hooks are derived from the user's system hooks plus Orca's
// managed hooks. Reusing old runtime user-hook copies would keep deleted or
// edited ~/.codex/hooks.json entries alive for new Orca-launched sessions.
// Why: rebuild from system hooks; reusing old runtime copies would keep deleted/edited ~/.codex/hooks.json entries alive for new sessions.
nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions)
}
@@ -364,8 +353,7 @@ function getTrustedSystemUserHookSignatures(
try {
trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath())
} catch (error) {
// Why: a hand-broken system config.toml should only disable user-hook
// trust mirroring; Orca's managed runtime hooks can still be installed.
// Why: a hand-broken system config.toml should only disable user-hook trust mirroring, not block Orca's managed runtime hooks.
console.warn('[codex-hook-service] failed to read system hook trust entries', error)
return signatures
}
@@ -396,8 +384,7 @@ function getTrustedSystemUserHookSignatures(
return
}
const signature = getCodexHookTrustSignature(entry)
// Why: runtime deduping collapses identical system hook definitions;
// if any duplicate remains enabled, keep the mirrored hook enabled.
// Why: runtime deduping collapses identical definitions; if any duplicate stays enabled, keep the mirrored hook enabled.
if (state.enabled || !signatures.has(signature)) {
signatures.set(signature, state)
}
@@ -422,11 +409,7 @@ function resolveTrustedSystemHookState(
return { enabled: reorderedEnabled, trustedHash: expectedHash }
}
if (state?.trustedHash) {
// Why: carry a key-matched system hash verbatim instead of dropping it as
// stale. Codex is the authority on its own hash algorithm; recomputing
// here is what turned #7110-style hash drift into an endless re-approval
// loop. If the hash is genuinely stale (edited hook), Codex prompts —
// exactly what a plain ~/.codex session would do.
// Why: carry a key-matched system hash verbatim — recomputing caused #7110 re-approval loops since Codex owns its hash algorithm.
return { enabled: state.enabled !== false, trustedHash: state.trustedHash }
}
return null
@@ -452,8 +435,7 @@ function getTrustedSystemHookHashesByEvent(
trustedHashesByEvent.set(parsed.eventLabel, hashes)
}
const enabled = state.enabled !== false
// Why: Codex trust keys include hook indices. If a user reorders hooks,
// the hash still proves the same event+command identity was approved.
// Why: Codex trust keys include hook indices, but the hash still proves the same event+command identity was approved after a reorder.
if (enabled || !hashes.has(state.trustedHash)) {
hashes.set(state.trustedHash, enabled)
}
@@ -539,8 +521,7 @@ function buildHookTrustHeaderKeyPattern(key: string): string {
const alternatives = [...new Set(keyVariants)].flatMap((variant) => {
const quoted = [`"${escapeRegex(escapeTomlString(variant))}"`]
if (!variant.includes("'")) {
// Why: tolerate raw-backslash literal keys left by Codex/manual approval
// while Orca repairs mirrored runtime trust across both Windows variants.
// Why: tolerate raw-backslash literal keys from Codex/manual approval while repairing mirrored runtime trust across both Windows variants.
quoted.push(`'${escapeRegex(variant)}'`)
}
return quoted
@@ -646,8 +627,7 @@ function cleanupLegacySystemManagedHooks(): void {
}
}
// Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in
// ~/.codex would keep external Codex sessions reporting into Orca.
// Why: Codex hooks moved to Orca's managed CODEX_HOME; stale ~/.codex entries would keep external Codex sessions reporting into Orca.
if (removedManagedHook) {
// Why: this is the user's system hooks file, not Orca's runtime copy.
// Remove only stale Orca hook entries and preserve other managers' metadata.
@@ -712,8 +692,7 @@ function cleanupLegacyCodexProfileHooks(): void {
if (next === existing) {
return
}
// Why: #2778 wrote Orca hooks into a Codex profile file. Runtime CODEX_HOME
// supersedes that representation, so remove only Orca's marked block.
// Why: #2778 wrote Orca hooks into a Codex profile file; runtime CODEX_HOME supersedes it, so remove only Orca's marked block.
if (next.trim().length === 0) {
unlinkSync(profilePath)
} else {
@@ -742,8 +721,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void {
sourceUsesExplicitCodexHome: true
})
} catch (error) {
// Best effort — stale trust entries are harmless once hooks.json no
// longer references the hook. Log so a programmer error doesn't disappear silently.
// Best effort — stale trust is harmless once hooks.json no longer references the hook; log so a programmer error isn't silent.
console.warn('[codex-hook-service] failed to clean trust entries', error)
}
}
@@ -759,8 +737,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
} catch (error) {
// Why: removing disabled WSL status hooks should be best-effort like the
// host cleanup path; stale trust is inert once hooks.json no longer points at us.
// Why: best-effort like host cleanup; stale trust is inert once hooks.json no longer points at us.
console.warn('[codex-hook-service] failed to clean WSL trust entries', error)
}
}
@@ -787,10 +764,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'@echo off',
'setlocal',
// Why: see claude/hook-service.ts for rationale. The endpoint file holds
// the live port/token for this Orca install; sourcing it here lets a
// surviving PTY reach the current server even though its env points at
// the prior Orca's coordinates.
// Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts).
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookCurlPostCommand('codex'),
@@ -803,15 +777,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
// Why: see claude/hook-service.ts for rationale. Sourcing refreshes
// PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps
// reporting after a restart.
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'load_hook_endpoint() {',
' endpoint_path="$1"',
' case "$endpoint_path" in',
' *.cmd)',
// Why: Windows passes endpoint.cmd into WSL through WSLENV path translation.
// Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax.
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
' endpoint_cr=$(printf "\\r")',
' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do',
' endpoint_line=${endpoint_line%"$endpoint_cr"}',
@@ -838,13 +809,8 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' curl_bin="$1"',
' connect_timeout="${2:-0.5}"',
' max_time="${3:-1.5}"',
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
' --noproxy "127.0.0.1" \\',
@@ -1000,8 +966,7 @@ function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): Agent
writeCodexHooksJson(plan.configPath, nextHooks)
removeWslRuntimeManagedHookTrustEntries(plan)
try {
// Why: the disabled path may be reached after the WSL mount root changed,
// so cleanup cannot be scoped only to the plan's current source path.
// Why: the disabled path may run after the WSL mount root changed, so cleanup can't be scoped to the plan's current source path.
removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, [])
} catch (error) {
console.warn('[codex-hook-service] failed to clean stale WSL trust entries', error)
@@ -1015,8 +980,7 @@ function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): Agent
}
}
// Why: transport failures preserve the last known-good identity, while a
// successful absence probe is strong enough to revoke trust immediately.
// Why: transport failures preserve last known-good identity; a successful absence probe is strong enough to revoke trust immediately.
function getWslHookReconciliationAction(args: {
settlement: WslCanonicalPathSettlement
isCurrentGeneration: boolean
@@ -1046,8 +1010,7 @@ function getWslHookReconciliationAction(args: {
return 'reinstall'
}
// Why: fold only the Windows-case-insensitive portion — a full lowercase would
// let case-distinct WSL runtime homes share one reconciliation generation slot.
// Why: fold only the Windows-case-insensitive portion; a full lowercase would let case-distinct WSL homes share one reconciliation slot.
function getWslReconciliationKey(runtimeHomePath: string): string {
return normalizeCodexProjectPathForLookup(runtimeHomePath)
}
@@ -1162,9 +1125,7 @@ export class CodexHookService {
}
}
// Why: Report `partial` when managed events are missing OR when their
// trust entries are missing/stale. Codex 0.129+ silently drops untrusted
// hooks, so a green status without trust verification is misleading.
// Why: Codex 0.129+ silently drops untrusted hooks, so report `partial` when managed events OR their trust entries are missing/stale.
const command = getManagedCommand(scriptPath)
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
// Why: an unreadable config.toml (EACCES/EIO) is distinct from "file
@@ -1205,16 +1166,12 @@ export class CodexHookService {
let presentCount = 0
for (const eventName of CODEX_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
// Why: older installs appended this command, while current installs
// prepend it. Picking the last match keeps status repair conservative
// if duplicate managed definitions survive from a stale hooks.json.
// Why: older installs appended, current ones prepend; last-match keeps status repair conservative when stale duplicate definitions survive.
let foundGroupIndex = -1
let foundHandlerIndex = -1
definitions.forEach((definition, idx) => {
const hooks = definition.hooks ?? []
// Why: mirror the LAST-match-wins rule at the group level — if a user
// merged hook arrays and ended up with our command at multiple indices
// in one group, the surviving runtime entry is the last one.
// Why: last-match-wins at the group level — if merged hook arrays repeat our command, the surviving runtime entry is the last one.
const handlerIdx = hooks.findLastIndex((hook) => hook.command === command)
if (handlerIdx !== -1) {
foundGroupIndex = idx
@@ -1226,14 +1183,9 @@ export class CodexHookService {
continue
}
presentCount += 1
// Why: a stale hash blocks firing the same as a missing entry, so
// compare against the canonical hash we would write.
// Why: capture the actual handler index — Codex's hook_key uses the
// positional handlerIndex, and a user-merged hook array can put our
// command at a non-zero slot, so hardcoding 0 would misreport trust.
// Why: the managed hook is written with `timeout` (see install()), and
// Codex folds the handler timeout into its trust hash. Hash the same
// timeout here or status would report every managed hook as stale-trust.
// Why: a stale hash blocks firing like a missing entry, so compare against the canonical hash we would write.
// Why: Codex's hook_key is positional, so hardcoding handlerIndex 0 misreports trust for user-merged hook arrays.
// Why: hash the same `timeout` install() writes, since Codex folds it into the trust hash or every managed hook reports stale-trust.
const trustInput: CodexTrustEntry = {
sourcePath: trustSourcePath,
eventLabel: CODEX_EVENT_LABEL[eventName],
@@ -1267,8 +1219,7 @@ export class CodexHookService {
let detail: string | null
if (presentCount === 0) {
state = 'not_installed'
// Why: surface the trust read error even when not_installed so the user
// has actionable info if config.toml is broken.
// Why: surface the trust read error even when not_installed, so a broken config.toml gives actionable info.
detail = trustReadError !== null ? `Trust entries unverifiable: ${trustReadError}` : null
} else if (
missing.length === 0 &&
@@ -1319,29 +1270,20 @@ export class CodexHookService {
}
}
// Why: match by script filename (not exact command string) so a fresh
// install sweeps stale entries left by older builds or a different
// Electron userData path (dev vs. prod). Without this, repeated installs
// accumulate duplicate hook entries pointing at defunct scripts.
// Why: match by script filename (not exact command) so a fresh install sweeps stale entries from older builds or a different userData path.
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const command = getManagedCommand(scriptPath)
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath)
const nextHooks = hookPlan.hooks
const managedEvents = new Set<string>(CODEX_EVENTS)
// Why: sweep managed entries out of events we no longer subscribe to
// (e.g., PreToolUse from a prior install). Without this, users who
// already had PreToolUse registered would keep firing stale hooks on
// every auto-approved tool call after the app upgrade.
// Why: sweep managed entries from events we no longer subscribe to (e.g. a prior install's PreToolUse), else they keep firing stale hooks after upgrade.
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName)) {
continue
}
if (!Array.isArray(definitions)) {
// Why: a malformed hooks.json entry (non-array value for an event name)
// would make removeManagedCommands throw. Skip instead — we aren't
// going to sweep something we can't parse, and the install() for
// managed events below still runs.
// Why: a non-array event value would make removeManagedCommands throw; skip the unparsable entry, managed events below still install.
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
@@ -1352,10 +1294,7 @@ export class CodexHookService {
}
}
// Why: Codex 0.129+ requires a per-hook trust entry in config.toml or the
// hook sits in the "review required" pile. We compute the trust hash for
// each managed entry as we install it and persist it alongside hooks.json
// so the user does not have to /hooks-approve after every install.
// Why: Codex 0.129+ requires a per-hook config.toml trust entry or the hook needs manual /hooks-approve; precompute the hash to avoid that.
const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook(
hookPlan.trustEntries
)
@@ -1391,9 +1330,8 @@ export class CodexHookService {
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeCodexHooksJson(configPath, nextHooks)
// Why: trust entries write last so a half-write can't leave a hash
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
// Why: trust entries write last so a half-write can't leave a hash pointing at a nonexistent hook.
// Why: surface trust-write failures — otherwise getStatus reports green for a hook Codex won't fire.
try {
const tomlPath = getCodexConfigTomlPath(runtimeHomePath)
syncSystemConfigIntoManagedCodexHome({
@@ -1453,14 +1391,9 @@ export class CodexHookService {
sftp: SFTPWrapper,
remoteHome: string,
options?: {
/** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its
* root). WSL sessions read Orca's managed runtime home, not ~/.codex —
* installing to the default location leaves those sessions hookless. */
/** Explicit CODEX_HOME dir (flat layout). WSL sessions read Orca's managed runtime home, not ~/.codex, so the default location leaves them hookless. */
codexHomeDir?: string
/** Skip the trust write when config.toml doesn't exist yet. The WSL
* runtime home's config.toml is seeded only-if-absent by the launch
* path; creating it here first would silently cancel that seed. A
* later (idempotent) reinstall upserts trust once the seed lands. */
/** Skip the trust write when config.toml is absent — the WSL launch path seeds it only-if-absent, so creating it here would cancel that seed. */
deferTrustUntilConfigToml?: boolean
}
): Promise<AgentHookInstallStatus> {
@@ -1517,13 +1450,10 @@ export class CodexHookService {
}
config.hooks = nextHooks
// Why: script/settings first, trust TOML last. A partial trust write
// leaves Codex asking for approval rather than executing a missing script.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
// Why: write script/settings before trust TOML; a partial trust write leaves Codex asking approval instead of running a missing script.
// Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
// Why: SSH installs edit the user's remote ~/.codex/hooks.json directly.
// Preserve non-Orca top-level metadata while replacing the hooks tree.
// Why: SSH edits the user's remote ~/.codex/hooks.json directly, so preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath)
@@ -1580,8 +1510,7 @@ export class CodexHookService {
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
const config = readHooksJson(configPath)
if (!config) {
// Why: disabled launch prep used to call remove(); preserve its legacy
// cleanup behavior even when runtime hooks.json is malformed.
// Why: disabled launch prep once called remove(); preserve that legacy cleanup even when runtime hooks.json is malformed.
cleanupLegacyManagedHookRepresentations()
return {
agent: 'codex',
@@ -1631,8 +1560,7 @@ export class CodexHookService {
const configExists = existsSync(configPath)
const config = readHooksJson(configPath)
if (!config) {
// Why: a malformed runtime hooks.json should not strand old hooks in
// ~/.codex or the legacy profile after the user disables Codex hooks.
// Why: a malformed hooks.json shouldn't strand old hooks in ~/.codex or the legacy profile after disabling.
cleanupLegacyManagedHookRepresentations()
return {
agent: 'codex',
@@ -1644,14 +1572,11 @@ export class CodexHookService {
}
const nextHooks = { ...config.hooks }
// Why: same broad matcher as install(), so remove() also cleans up stale
// entries from older builds even if the current scriptPath has moved.
// Why: same broad matcher as install() so stale entries from older builds get cleaned even if scriptPath moved.
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
// Why: a malformed hooks.json entry (non-array value for an event name)
// would make removeManagedCommands throw. Skip instead — we have no
// managed commands to remove from something we can't parse.
// Why: a non-array event value would make removeManagedCommands throw; skip it.
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
@@ -1662,14 +1587,11 @@ export class CodexHookService {
}
}
if (configExists) {
// Why: remove() can be the only repair path for a parseable runtime file
// whose top-level plugin metadata makes Codex reject hooks.json.
// Why: remove() may be the only repair path for a file whose top-level plugin metadata makes Codex reject hooks.json.
writeCodexHooksJson(configPath, nextHooks)
}
// Why: also drop our trust entries so config.toml doesn't accumulate dead
// [hooks.state."..."] blocks across install/remove cycles. Best-effort —
// a stale entry is harmless once hooks.json no longer references it.
// Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles.
removeRuntimeManagedHookTrustEntries(configPath)
cleanupLegacyManagedHookRepresentations()
+17 -68
View File
@@ -26,22 +26,8 @@ import {
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
// Why: cursor-agent exposes a declarative hooks.json surface at
// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event
// names. We subscribe to the minimum set that drives the sidebar spinner and
// turn-boundary detection:
// - beforeSubmitPrompt: new turn starts (carries the user's prompt)
// - stop: turn ends (→ done)
// - preToolUse/postToolUse/postToolUseFailure: in-flight tool preview
// between submit and stop — without these the pane appears idle for the
// entire duration of a long tool-heavy turn
// - beforeShellExecution / beforeMCPExecution: shell/MCP tool preview (→ working)
// - afterAgentResponse: carries the final composed reply text so the
// dashboard can surface it on done
// sessionStart / sessionEnd are intentionally NOT subscribed — cursor-agent
// fires them at process lifetime boundaries rather than at turn boundaries,
// and sessionStart's fire-time can race the first beforeSubmitPrompt in a
// way that would reset the prompt cache for the just-submitted turn.
// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection.
// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache.
const CURSOR_EVENTS = [
'beforeSubmitPrompt',
'stop',
@@ -76,10 +62,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'@echo off',
'setlocal',
// Why: see claude/hook-service.ts for rationale. The endpoint file holds
// the live port/token for this Orca install; sourcing it here lets a
// surviving PTY reach the current server even though its env points at
// the prior Orca's coordinates.
// Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts).
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookPostCommand('cursor'),
@@ -92,22 +75,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
// Why: see claude/hook-service.ts for rationale. Sourcing refreshes
// PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps
// reporting after a restart.
// Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead.
// Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
@@ -144,9 +120,7 @@ export class CursorHookService {
let presentCount = 0
for (const eventName of CURSOR_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
// Why: Cursor's schema places the command directly on the definition
// (not nested under a `hooks` array as Claude does), so match both
// shapes to stay robust against future schema changes.
// Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes.
const hasCommand = definitions.some(
(definition) =>
definition.command === command ||
@@ -189,22 +163,14 @@ export class CursorHookService {
}
const command = getManagedCommand(scriptPath)
// Why: Cursor's hooks.json wraps the map under a top-level "hooks" key
// (same as Claude/Codex). A fresh file with no prior hook install will
// have config.hooks === undefined.
// Why: config.hooks is undefined on a fresh file with no prior hook install.
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CURSOR_EVENTS)
// Why: match by script filename (not exact command string) so a fresh
// install sweeps stale entries left by older builds or a different
// Electron userData path (dev vs. prod). Without this, repeated installs
// accumulate duplicate hook entries pointing at defunct scripts.
// Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
// Why: sweep managed entries out of events we no longer subscribe to
// (e.g. a future rename where we drop an event name). Without this,
// users who already had that event registered would keep firing stale
// hooks after the app upgrade.
// Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks.
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName)) {
continue
@@ -226,24 +192,16 @@ export class CursorHookService {
for (const eventName of CURSOR_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Sweep both the Claude-shaped (hooks[].command) and Cursor-shaped
// (definition.command) variants so repeated installs converge on a
// single managed entry.
// Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
// Why: Cursor's documented schema puts `command` directly on the
// definition (not under `hooks`). Emit that shape so cursor-agent
// actually invokes the script.
// Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape.
const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
}
// Why: cursor-agent's config schema requires a top-level `version: 1`
// (see https://cursor.com/docs/hooks). Preserve an existing value if the
// user has already pinned one, otherwise stamp the default so a fresh
// install produces a valid file. HooksConfig has an index signature on
// extra keys, so assign via a Record-typed object to satisfy the type.
// Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value.
const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
if (nextConfig.version === undefined) {
nextConfig.version = 1
@@ -253,12 +211,7 @@ export class CursorHookService {
return this.getStatus()
}
// Why: install Orca's managed Cursor hooks on the remote box. Mirrors
// ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
// primitives, and emits Cursor's documented schema (top-level `command`
// on each definition + top-level `version: 1`) so cursor-agent on the
// remote actually invokes the script. See docs/design/agent-status-over-ssh.md
// §8.
// Installs managed Cursor hooks on an SSH remote (POSIX-only). See docs/design/agent-status-over-ssh.md §8.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh`
@@ -280,8 +233,7 @@ export class CursorHookService {
for (const eventName of CURSOR_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
// Why: same dual-shape sweep as the local install — repeated
// installs converge on a single managed entry.
// Why: dual-shape sweep so repeated installs converge on a single managed entry.
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
@@ -294,11 +246,8 @@ export class CursorHookService {
nextConfig.version = 1
}
// Why: script-then-config order so a partial-failure mid-install at
// worst leaves a working script no settings.json points at — see
// ClaudeHookService.installRemote.
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
// running on Windows; never derive remote script syntax from local OS.
// Why: script-then-config order so a partial mid-install leaves a working script nothing points at.
// Why: SSH remotes always use POSIX `.sh` hook paths even when Orca runs on Windows; never derive from local OS.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
+97 -3
View File
@@ -68,6 +68,12 @@ describe('DaemonClient', () => {
onStreamHello?: (msg: HelloMessage) => void
rejectVersion?: boolean
suppressHelloResponse?: boolean
omitHelloIdentity?: boolean
helloIdentity?: (role: 'control' | 'stream') => {
pid: number
startedAtMs: number
launchNonce: string
}
}): Promise<void> {
return new Promise((resolve) => {
server = createServer((socket) => {
@@ -103,7 +109,19 @@ describe('DaemonClient', () => {
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' }))
return
}
socket.write(encodeNdjson({ type: 'hello', ok: true }))
socket.write(
encodeNdjson({
type: 'hello',
ok: true,
...(!opts?.omitHelloIdentity
? {
daemonIdentity: opts?.helloIdentity
? opts.helloIdentity(hello.role)
: { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' }
}
: {})
})
)
if (hello.role === 'stream') {
opts?.onStreamHello?.(hello)
}
@@ -136,6 +154,48 @@ describe('DaemonClient', () => {
await waitFor(() => hellos.length > 0)
})
it('captures one matching endpoint identity from both authenticated sockets', async () => {
const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' }
await startMockDaemon({ helloIdentity: () => identity })
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(client.getDaemonIdentity()).toEqual(identity)
})
it('rejects a v24 daemon that omits endpoint identity', async () => {
await startMockDaemon({ omitHelloIdentity: true })
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity')
})
it('allows a legacy v23 daemon to omit endpoint identity', async () => {
await startMockDaemon({ omitHelloIdentity: true })
client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 23 })
await expect(client.ensureConnected()).resolves.toBeUndefined()
expect(client.getDaemonIdentity()).toBeNull()
})
it('rejects when control and stream sockets report different daemon identities', async () => {
await startMockDaemon({
helloIdentity: (role) => ({
pid: role === 'control' ? 123 : 124,
startedAtMs: 456,
launchNonce: 'launch-a'
})
})
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow(
'Daemon identity changed during connection'
)
expect(client.getDaemonIdentity()).toBeNull()
})
it('removes socket startup listeners after connecting', async () => {
await startMockDaemon()
@@ -189,6 +249,35 @@ describe('DaemonClient', () => {
}
})
it('bounds a waiter on an existing connection attempt and prevents socket resurrection', async () => {
let resolveHello: () => void = () => {}
const helloReceived = new Promise<void>((resolve) => {
resolveHello = resolve
})
await startMockDaemon({
suppressHelloResponse: true,
onHello: resolveHello
})
client = new DaemonClient({ socketPath, tokenPath })
const ownerAttempt = client.ensureConnected().catch((error: Error) => error)
await helloReceived
await expect(client.ensureConnectedWithin(25)).rejects.toThrow(
'Connection attempt wait timed out'
)
client.disconnect()
await expect(ownerAttempt).resolves.toBeInstanceOf(Error)
await new Promise((resolve) => setTimeout(resolve, 25))
const disconnected = client as unknown as {
controlSocket: Socket | null
streamSocket: Socket | null
}
expect(client.isConnected()).toBe(false)
expect(disconnected.controlSocket).toBeNull()
expect(disconnected.streamSocket).toBeNull()
})
it('removes hello startup listeners after timeout', async () => {
vi.useFakeTimers()
@@ -200,11 +289,16 @@ describe('DaemonClient', () => {
socket.destroy = destroy as unknown as Socket['destroy']
const sendHello = (
client as unknown as {
sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise<void>
sendHello(
socket: Socket,
token: string,
role: 'control' | 'stream',
timeoutMs: number
): Promise<void>
}
).sendHello.bind(client)
const promise = sendHello(socket, 'test-token-123', 'control')
const promise = sendHello(socket, 'test-token-123', 'control', 5000)
const rejection = expect(promise).rejects.toThrow('Hello response timed out')
await vi.advanceTimersByTimeAsync(5000)
+169 -19
View File
@@ -4,11 +4,23 @@ import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
NOTIFY_PREFIX,
DaemonProtocolError
} from './types'
import type {
DaemonEndpointIdentity,
HelloMessage,
HelloResponse,
RpcResponse,
DaemonEvent
} from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
const CONNECT_TIMEOUT_MS = 5000
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
const REQUEST_TIMEOUT_MS = 30000
export type DaemonClientOptions = {
@@ -42,6 +54,9 @@ export class DaemonClient {
// all call ensureConnected(). Without a lock, each starts a separate
// connection attempt, overwriting sockets and triggering "Connection lost".
private connectingPromise: Promise<void> | null = null
private connectionAttemptGeneration = 0
private daemonIdentity: DaemonEndpointIdentity | null = null
private observedAuthenticatedDisconnect = false
private pendingRequests = new Map<string, PendingRequest>()
private eventListeners: ((event: unknown) => void)[] = []
@@ -59,15 +74,38 @@ export class DaemonClient {
return this.connected
}
getDaemonIdentity(): DaemonEndpointIdentity | null {
return this.daemonIdentity ? { ...this.daemonIdentity } : null
}
hasObservedAuthenticatedDisconnect(): boolean {
return this.observedAuthenticatedDisconnect
}
async ensureConnected(): Promise<void> {
return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false)
}
async ensureConnectedWithin(timeoutMs: number): Promise<void> {
return this.ensureConnectedWithTimeout(timeoutMs, true)
}
private async ensureConnectedWithTimeout(
timeoutMs: number,
sharedBudget: boolean
): Promise<void> {
if (this.connected) {
return
}
if (this.connectingPromise) {
return this.connectingPromise
// Why: a normal connection may legitimately consume one timeout for each
// socket and hello; bounded teardown calls instead keep their one shared budget.
const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS
return this.waitForConnectionAttempt(this.connectingPromise, waiterTimeoutMs)
}
this.connectingPromise = this.doConnect()
const attemptGeneration = this.connectionAttemptGeneration
this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget)
try {
await this.connectingPromise
} finally {
@@ -75,8 +113,15 @@ export class DaemonClient {
}
}
private async doConnect(): Promise<void> {
private async doConnect(
timeoutMs: number,
attemptGeneration: number,
sharedBudget: boolean
): Promise<void> {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
const deadlineMs = Date.now() + timeoutMs
const remainingMs = (): number =>
sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs
const pendingListenerCleanups: (() => void)[] = []
const cleanupPendingListeners = (): void => {
for (const cleanup of pendingListenerCleanups.splice(0)) {
@@ -86,15 +131,32 @@ export class DaemonClient {
try {
// Sequential: control first, then stream
this.controlSocket = await this.connectSocket()
await this.sendHello(this.controlSocket, token, 'control')
const pendingControlSocket = await this.connectSocket(remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket)
this.controlSocket = pendingControlSocket
const controlIdentity = await this.sendHello(
this.controlSocket,
token,
'control',
remainingMs()
)
this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket)
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket))
this.streamSocket = await this.connectSocket()
await this.sendHello(this.streamSocket, token, 'stream')
const pendingStreamSocket = await this.connectSocket(remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket)
this.streamSocket = pendingStreamSocket
const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket)
if (!sameDaemonIdentity(controlIdentity, streamIdentity)) {
throw new DaemonProtocolError('Daemon identity changed during connection')
}
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket))
this.assertConnectionAttemptCurrent(attemptGeneration)
this.connected = true
this.observedAuthenticatedDisconnect = false
this.daemonIdentity = controlIdentity
this.disconnectArmed = true
this.connectionGeneration++
@@ -120,12 +182,17 @@ export class DaemonClient {
this.controlSocket = null
this.streamSocket = null
this.connected = false
this.daemonIdentity = null
this.disconnectArmed = false
throw error
}
}
async request<T = unknown>(type: string, payload: unknown): Promise<T> {
async request<T = unknown>(
type: string,
payload: unknown,
timeoutMs = REQUEST_TIMEOUT_MS
): Promise<T> {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
}
@@ -136,8 +203,8 @@ export class DaemonClient {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`))
}, REQUEST_TIMEOUT_MS)
reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
@@ -180,7 +247,9 @@ export class DaemonClient {
}
disconnect(): void {
this.connectionAttemptGeneration++
this.connected = false
this.daemonIdentity = null
this.disconnectArmed = false
this.cleanupActiveSocketListeners()
@@ -196,7 +265,7 @@ export class DaemonClient {
this.streamSocket = null
}
private connectSocket(): Promise<Socket> {
private connectSocket(timeoutMs: number): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = connect(this.socketPath)
const cleanup = (): void => {
@@ -216,14 +285,45 @@ export class DaemonClient {
cleanup()
socket.destroy()
reject(new DaemonProtocolError('Connection timed out'))
}, CONNECT_TIMEOUT_MS)
}, timeoutMs)
socket.on('connect', onConnect)
socket.on('error', onError)
})
}
private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise<void> {
private waitForConnectionAttempt(attempt: Promise<void>, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new DaemonProtocolError('Connection attempt wait timed out'))
}, timeoutMs)
attempt.then(
() => {
clearTimeout(timer)
resolve()
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void {
if (attemptGeneration === this.connectionAttemptGeneration) {
return
}
socket?.destroy()
throw new DaemonProtocolError('Disconnected')
}
private sendHello(
socket: Socket,
token: string,
role: 'control' | 'stream',
timeoutMs: number
): Promise<DaemonEndpointIdentity | null> {
return new Promise((resolve, reject) => {
const hello: HelloMessage = {
type: 'hello',
@@ -245,7 +345,7 @@ export class DaemonClient {
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
}
const finish = (error?: Error): void => {
const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => {
if (settled) {
return
}
@@ -255,7 +355,7 @@ export class DaemonClient {
reject(error)
return
}
resolve()
resolve(identity)
}
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
// Decoding each Buffer independently would permanently inject U+FFFD.
@@ -271,7 +371,15 @@ export class DaemonClient {
try {
const response = JSON.parse(line) as HelloResponse
if (response.ok) {
finish()
const identity = parseDaemonEndpointIdentity(response.daemonIdentity)
if (
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION && identity === null) ||
(response.daemonIdentity !== undefined && identity === null)
) {
finish(new DaemonProtocolError('Invalid daemon identity'))
return
}
finish(undefined, identity)
} else {
finish(
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
@@ -290,7 +398,7 @@ export class DaemonClient {
// without a handshake timeout, startup waits forever on ensureConnected().
finish(new DaemonProtocolError('Hello response timed out'))
socket.destroy()
}, CONNECT_TIMEOUT_MS)
}, timeoutMs)
socket.on('data', onData)
socket.on('error', onError)
socket.on('close', onClose)
@@ -352,7 +460,12 @@ export class DaemonClient {
return
}
this.disconnectArmed = false
this.connectionAttemptGeneration++
if (this.daemonIdentity) {
this.observedAuthenticatedDisconnect = true
}
this.connected = false
this.daemonIdentity = null
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
@@ -377,3 +490,40 @@ export class DaemonClient {
cleanup?.()
}
}
function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null {
if (!value || typeof value !== 'object') {
return null
}
const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown }
if (
!Number.isSafeInteger(identity.pid) ||
(identity.pid as number) <= 0 ||
typeof identity.startedAtMs !== 'number' ||
!Number.isFinite(identity.startedAtMs) ||
identity.startedAtMs <= 0 ||
typeof identity.launchNonce !== 'string' ||
identity.launchNonce.length === 0
) {
return null
}
return {
pid: identity.pid as number,
startedAtMs: identity.startedAtMs,
launchNonce: identity.launchNonce
}
}
function sameDaemonIdentity(
left: DaemonEndpointIdentity | null,
right: DaemonEndpointIdentity | null
): boolean {
return (
(left === null && right === null) ||
(left !== null &&
right !== null &&
left.pid === right.pid &&
left.startedAtMs === right.startedAtMs &&
left.launchNonce === right.launchNonce)
)
}
+43
View File
@@ -51,6 +51,49 @@ describe('daemon-entry parseArgs', () => {
})
})
it('parses the internal PID-record ownership pair', () => {
expect(
parseArgs([
'--socket',
'/tmp/t.sock',
'--token',
'/tmp/t.token',
'--pid-record',
'/tmp/t.pid',
'--launch-nonce',
'launch-a'
])
).toEqual({
socketPath: '/tmp/t.sock',
tokenPath: '/tmp/t.token',
pidPath: '/tmp/t.pid',
launchNonce: 'launch-a'
})
})
it('rejects either PID-record ownership argument without its pair', () => {
expect(() =>
parseArgs([
'--socket',
'/tmp/t.sock',
'--token',
'/tmp/t.token',
'--pid-record',
'/tmp/t.pid'
])
).toThrow('provided together')
expect(() =>
parseArgs([
'--socket',
'/tmp/t.sock',
'--token',
'/tmp/t.token',
'--launch-nonce',
'launch-a'
])
).toThrow('provided together')
})
it('still requires --socket and --token when --log-file is given', () => {
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
})
+35 -4
View File
@@ -17,6 +17,8 @@ import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
export type ParsedDaemonArgs = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
/** Optional — absent for adopted old daemons and tests, which log nothing. */
logFilePath?: string
}
@@ -25,6 +27,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
let socketPath = ''
let tokenPath = ''
let logFilePath = ''
let pidPath = ''
let launchNonce = ''
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--socket' && argv[i + 1]) {
@@ -36,6 +40,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
} else if (argv[i] === '--log-file' && argv[i + 1]) {
logFilePath = argv[i + 1]
i++
} else if (argv[i] === '--pid-record' && argv[i + 1]) {
pidPath = argv[i + 1]
i++
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
launchNonce = argv[i + 1]
i++
}
}
@@ -43,7 +53,16 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
throw new Error('Usage: daemon-entry --socket <path> --token <path> [--log-file <path>]')
}
return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath }
if ((pidPath && !launchNonce) || (!pidPath && launchNonce)) {
throw new Error('Daemon PID record path and launch nonce must be provided together')
}
return {
socketPath,
tokenPath,
...(pidPath ? { pidPath, launchNonce } : {}),
...(logFilePath ? { logFilePath } : {})
}
}
async function main(): Promise<void> {
@@ -54,7 +73,10 @@ async function main(): Promise<void> {
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
process.stderr.on('error', () => {})
const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2))
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
process.argv.slice(2)
)
const startedAtMs = Date.now() - process.uptime() * 1000
// Fail-open: a broken log path must never block daemon startup.
const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog()
daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath })
@@ -126,16 +148,25 @@ async function main(): Promise<void> {
daemon = await startDaemon({
socketPath,
tokenPath,
...(pidPath ? { pidPath } : {}),
...(launchNonce ? { launchNonce } : {}),
...(pidPath ? { startedAtMs } : {}),
log: daemonLog,
preparePtySpawn: prepareMacosTccLoginShell,
spawnSubprocess: (opts) => createPtySubprocess(opts)
spawnSubprocess: (opts) => createPtySubprocess(opts),
onIdleShutdown: () => {
shuttingDown = true
daemonLog.log('shutdown', { reason: 'idle' })
daemonLog.close()
process.exit(0)
}
})
// Signal readiness to parent via IPC (if available)
if (process.send) {
// Why: Windows has no cheap OS query for a child's start time, so the
// daemon self-reports it here for the pid file's pid-recycling guard.
process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 })
process.send({ type: 'ready', startedAtMs })
}
daemonLog.log('ready')
@@ -3,7 +3,9 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(19)
expect(PROTOCOL_VERSION).toBe(24)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
})
})
+20
View File
@@ -0,0 +1,20 @@
export type HelloMessage = {
type: 'hello'
version: number
token: string
clientId: string
role: 'control' | 'stream'
}
export type DaemonEndpointIdentity = {
pid: number
startedAtMs: number
launchNonce: string
}
export type HelloResponse = {
type: 'hello'
ok: boolean
error?: string
daemonIdentity?: DaemonEndpointIdentity
}
+28 -84
View File
@@ -14,26 +14,11 @@ import { app } from 'electron'
import { parseDaemonPidFile, startTimeMatches } from './daemon-health'
/**
* Relocates the terminal daemon's process image out of the app install
* directory into userData so it survives Windows auto-updates.
*
* Why: the daemon is forked as plain Node via ELECTRON_RUN_AS_NODE, so its
* image is the install-dir Orca.exe and its loaded modules (node-pty native,
* ConPTY runtime) map from the install dir. On update, electron-builder's NSIS
* installer deletes the old install and force-closes every process whose image
* lives under it — killing the daemon and every live terminal it owns. Copying
* the daemon's whole file closure to a version-keyed userData dir and forking
* from that copy takes its image + loaded modules out of the installer's reach.
*
* The copy keeps the ELECTRON binary run as node (not stock node.exe): a copy
* of Orca.exe (renamed to a distinct image name) is byte-identical, so
* run-as-node behavior — no console flashing, asar-correct — matches the in-dir
* fork exactly. The win-unpacked layout is mirrored verbatim so
* require('node-pty') and node-pty's native loader resolve the relocated tree
* identically to the packaged app.
*
* Fail-open everywhere: any failure returns null and the caller forks the
* install-dir host — the pre-relocation behavior, byte-identical off win32.
* Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it
* survives Windows auto-updates: the NSIS installer deletes the old install and force-kills every process
* imaged under it, which would otherwise kill the daemon and its live terminals. The relocated exe is a
* run-as-node Orca.exe copy (not node.exe) so there's no console flash and asar still resolves. Fail-open:
* any failure returns null and the caller forks the install-dir host (pre-relocation behavior).
*/
export type RelocatedDaemonHost = {
@@ -46,22 +31,13 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// The relocated host is machine-specific runtime (~260MB). It must live under
// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive
// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder
// name is shared verbatim with the NSIS uninstall cleanup
// (config/nsis/daemon-host-uninstall.nsh), which removes
// %LOCALAPPDATA%\<LOCAL_HOST_ROOT_NAME>\daemon-host — keep the two in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// The relocated host exe is a copy of Orca.exe renamed to a distinct image
// name. The NSIS updater's name-based kill (`taskkill /IM Orca.exe`) matches by
// image name, so a distinct name spares the daemon from that branch, while the
// userData path (outside $INSTDIR) spares it from the path-based branch.
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe'
// V8 snapshots + ICU data the Electron bootstrap reads even under
// ELECTRON_RUN_AS_NODE; siblings of Orca.exe in win-unpacked.
// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe.
const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']
type CopyOp = {
@@ -89,9 +65,7 @@ type MaterializeMarker = {
entryRelPath: string
}
// Uses win32 path semantics so Windows layout paths (drive letters, `\`)
// decompose correctly regardless of host OS — needed for cross-platform unit
// tests; production runs this on win32 only.
// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only.
function toPosixRelative(fromDir: string, absPath: string): string {
return winPath.relative(fromDir, absPath).split(winPath.sep).join('/')
}
@@ -100,8 +74,7 @@ function destPath(root: string, destRel: string): string {
return join(root, ...destRel.split('/'))
}
// Mirror getDaemonEntryPath()'s resolution order (unpacked root first, then
// out/main) so the copied entry is the exact file the in-dir fork would run.
// Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run.
function resolveEntrySourcePath(resourcesPath: string): string {
const unpackedRoot = join(resourcesPath, 'app.asar.unpacked')
const direct = join(unpackedRoot, 'daemon-entry.js')
@@ -111,8 +84,7 @@ function resolveEntrySourcePath(resourcesPath: string): string {
return join(unpackedRoot, 'out', 'main', 'daemon-entry.js')
}
// Discover the relocation inputs from the live packaged process, or null when
// relocation does not apply (non-win32, dev, or missing resourcesPath).
// Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath).
function collectDaemonHostSources(): DaemonHostSources | null {
if (process.platform !== 'win32' || !app.isPackaged) {
return null
@@ -133,12 +105,7 @@ function collectDaemonHostSources(): DaemonHostSources | null {
}
}
// node-pty ships debug symbols (.pdb) and a win32 prebuild dir per CPU arch; the
// run-as-node daemon loads neither the symbols nor any non-host-arch prebuild
// (verified against the live daemon's loaded module list), so they are filtered
// out of the copy — the bulk of node-pty's on-disk size. Keyed on the host arch
// rather than dropping arm64 outright so a future Windows-arm64 build keeps the
// `win32-arm64` prebuild it actually needs and prunes `win32-x64` instead.
// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs.
const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase()
function isRuntimeNodePtyPath(sourcePath: string): boolean {
const p = sourcePath.toLowerCase()
@@ -151,29 +118,20 @@ function isRuntimeNodePtyPath(sourcePath: string): boolean {
}
/**
* The ordered copy plan. Every destRel mirrors the source's win-unpacked
* relative path so require() and node-pty's native loader resolve the mirror
* identically to the packaged app. Pure over its inputs so tests can assert the
* layout without a real build.
* The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require()
* and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout.
*/
export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources
const ops: CopyOp[] = []
// Electron host binary + V8/ICU data blobs at the dest root. The exe is
// renamed to a distinct image name so the NSIS updater's name-based
// `taskkill /IM Orca.exe` can't match it; the blobs beside it are read by the
// Electron bootstrap by fixed name. Top-level DLLs are deliberately NOT copied
// — they are all GPU/graphics/media (swiftshader, vulkan, d3d, dxcompiler,
// ffmpeg) that a windowless run-as-node host never loads (verified empirically
// against the live daemon's module list), so copying them only wastes ~48MB.
// Host exe (renamed) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' })
for (const name of RUNTIME_DATA_FILES) {
ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true })
}
// Daemon bundle: entry + its sibling chunks/ + the unpacked out/package.json
// (CJS/ESM loader resolution), mirrored verbatim.
// Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim.
ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' })
const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks')
ops.push({
@@ -190,10 +148,7 @@ export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
optional: true
})
// node-pty package tree (native conpty.node + conpty/ runtime dir). It is a
// sibling of app.asar.unpacked; require('node-pty') resolves it by walking up
// from the mirrored daemon-entry dir to resources/node_modules. Filtered to
// drop .pdb debug symbols and other-arch prebuilds the host never loads.
// node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds.
const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty')
ops.push({
sourcePath: nodePtyDir,
@@ -245,9 +200,7 @@ function readMarker(dir: string): MaterializeMarker | null {
}
function hostRootDir(): string {
// Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME). Fall back to userData only
// if LOCALAPPDATA is somehow unset — a no-op off win32, where relocation never
// runs anyway; on win32 packaged the env var is always present.
// Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset.
const localAppData = process.env.LOCALAPPDATA
const base =
typeof localAppData === 'string' && localAppData.length > 0
@@ -257,9 +210,8 @@ function hostRootDir(): string {
}
/**
* Cheap idempotency check: the relocated host for the current version, or null.
* Valid only when the marker matches this version AND the exe + entry exist, so
* a partial or stale copy never reports ready.
* The relocated host for the current version, or null. Valid only when the marker matches this version
* AND the exe + entry exist, so a partial or stale copy never reports ready.
*/
export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
const sources = collectDaemonHostSources()
@@ -281,11 +233,8 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
}
/**
* Ensure the current version's daemon host is materialized under
* userData/daemon-host/<version>, returning its fork paths or null (fail-open).
* Idempotent: a valid marker for this version short-circuits without recopying.
* The copy stages into a temp sibling and is published by atomic rename, so a
* crash mid-copy never leaves a half-populated dest.
* Materialize the current version's daemon host, returning its fork paths or null (fail-open). Idempotent
* via marker; stages into a temp sibling and publishes by atomic rename, so a crash mid-copy never leaves a half-populated dest.
*/
export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
const existing = getRelocatedDaemonHost()
@@ -304,8 +253,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
mkdirSync(root, { recursive: true })
rmSync(staging, { recursive: true, force: true })
executeManifest(buildDaemonHostManifest(sources), staging)
// Marker written LAST: an interrupted copy leaves a marker-less staging dir
// that the next launch discards, never a dest the cheap check trusts.
// Marker written LAST so an interrupted copy leaves a marker-less staging dir the next launch discards.
const marker: MaterializeMarker = {
version,
completedAt: new Date().toISOString(),
@@ -336,10 +284,8 @@ function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean {
}
/**
* App versions still pinned by a live daemon, read from the daemon-v<N>.pid
* files under `runtimeDir`. A surviving daemon runs from its version's host dir,
* so its dir must never be reclaimed while the process is alive. On win32 the
* start-time check cannot verify, so a matching pid pins conservatively.
* App versions still pinned by a live daemon (from daemon-v<N>.pid files under `runtimeDir`), whose
* host dir must not be reclaimed while alive. On win32 start-time can't verify, so a matching pid pins conservatively.
*/
export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
const pinned = new Set<string>()
@@ -359,8 +305,7 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
} catch {
continue
}
// appVersion null => a pre-relocation daemon forked from the install dir,
// which pins no host dir here.
// appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here.
if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) {
pinned.add(parsed.appVersion)
}
@@ -369,9 +314,8 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set<string> {
}
/**
* Reclaim daemon-host/<ver> dirs whose ver is neither the current version nor
* pinned by a live daemon. Best-effort — never throws; a still-locked or
* concurrently-staging dir is simply retried on a future launch.
* Reclaim daemon-host/<ver> dirs that are neither the current version nor pinned by a live daemon.
* Best-effort — never throws; a locked/staging dir is retried on a future launch.
*/
export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet<string>): void {
if (process.platform !== 'win32' || !app.isPackaged) {
@@ -0,0 +1,728 @@
import { EventEmitter } from 'node:events'
import { connect, type Socket } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { PROTOCOL_VERSION } from './types'
import {
getDaemonPidPath,
getDaemonSocketPath,
serializeDaemonPidFile,
unlinkOwnedDaemonPidFile
} from './daemon-spawner'
import type { SubprocessHandle } from './session'
type ManualTimer = {
callback: () => void
dueAt: number
cancelled: boolean
}
class ManualIdleClock {
private nowMs = 0
private timers = new Set<ManualTimer>()
setTimeout(callback: () => void, delayMs: number): ManualTimer {
const timer = { callback, dueAt: this.nowMs + delayMs, cancelled: false }
this.timers.add(timer)
return timer
}
clearTimeout(handle: unknown): void {
const timer = handle as ManualTimer
timer.cancelled = true
this.timers.delete(timer)
}
now(): number {
return this.nowMs
}
advanceBy(ms: number): void {
this.nowMs += ms
for (const timer of [...this.timers].sort((a, b) => a.dueAt - b.dueAt)) {
if (timer.cancelled || timer.dueAt > this.nowMs) {
continue
}
this.timers.delete(timer)
timer.callback()
}
}
get pendingCount(): number {
return this.timers.size
}
}
function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } {
let onExit: ((code: number) => void) | null = null
return {
pid: 9345,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
forceKill: vi.fn(),
signal: vi.fn(),
onData: vi.fn(),
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
exit(code) {
onExit?.(code)
}
}
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for daemon idle state')
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
async function requestOnRawSocket(
socket: Socket,
request: { id: string; type: string; payload: unknown }
): Promise<{ error?: string }> {
return new Promise((resolve, reject) => {
let buffer = ''
const cleanup = (): void => {
clearTimeout(timeout)
socket.off('data', onData)
}
const onData = (chunk: Buffer): void => {
buffer += chunk.toString('utf8')
for (;;) {
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = buffer.slice(0, newlineIndex)
buffer = buffer.slice(newlineIndex + 1)
const message = JSON.parse(line) as { id?: string; error?: string }
if (message.id === request.id) {
cleanup()
resolve(message)
return
}
}
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for raw response ${request.id}`))
}, 2_000)
socket.on('data', onData)
socket.write(`${JSON.stringify(request)}\n`)
})
}
describe('current daemon lifecycle retirement', () => {
let dir: string
let socketPath: string
let tokenPath: string
let pidPath: string
let clock: ManualIdleClock
let server: DaemonServer | null
let subprocess: ReturnType<typeof createMockSubprocess>
let onIdleShutdown: ReturnType<typeof vi.fn<() => void>>
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-idle-shutdown-'))
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
pidPath = getDaemonPidPath(dir)
clock = new ManualIdleClock()
subprocess = createMockSubprocess()
onIdleShutdown = vi.fn<() => void>()
server = null
})
afterEach(async () => {
await server?.shutdown().catch(() => {})
rmSync(dir, { recursive: true, force: true })
})
async function startServer(
options: {
launchNonce?: string
protocolVersion?: number
} = {}
): Promise<void> {
server = new DaemonServer({
socketPath,
tokenPath,
...(options.launchNonce ? { pidPath, launchNonce: options.launchNonce } : {}),
...(options.protocolVersion !== undefined
? { protocolVersion: options.protocolVersion }
: {}),
initialAdoptionTestConfig: { timeoutMs: 100, clock },
onIdleShutdown,
spawnSubprocess: () => subprocess
})
await server.start()
}
it('retires immediately after an unexpected empty disconnect and removes owned artifacts', async () => {
const launchNonce = 'launch-a'
writeFileSync(
pidPath,
serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce })
)
await startServer({ launchNonce })
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
client.disconnect()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
expect(existsSync(tokenPath)).toBe(false)
expect(existsSync(pidPath)).toBe(false)
if (process.platform !== 'win32') {
expect(existsSync(socketPath)).toBe(false)
}
})
it('retires a fresh daemon that is never adopted by a full client pair', async () => {
await startServer()
expect(clock.pendingCount).toBe(1)
clock.advanceBy(100)
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('retires immediately after an authenticated clean disconnect proves it is empty', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: true
})
client.disconnect()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
})
it('keeps resources alive until the shutdownIfIdle reply write flushes', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
chunk: string | Uint8Array,
...args: unknown[]
) => {
replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined
return originalWrite(chunk)
}) as unknown as Socket['write'])
const dispose = vi.spyOn(daemon.host, 'dispose')
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true })
expect(dispose).not.toHaveBeenCalled()
expect(onIdleShutdown).not.toHaveBeenCalled()
replyFlushed?.()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(dispose).toHaveBeenCalledOnce()
client.disconnect()
})
it('finishes shutdownIfIdle when the peer closes before its reply callback', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) =>
originalWrite(chunk)) as unknown as Socket['write'])
const dispose = vi.spyOn(daemon.host, 'dispose')
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true })
expect(dispose).not.toHaveBeenCalled()
client.disconnect()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(dispose).toHaveBeenCalledOnce()
})
it('connects and retires a never-used adapter during clean disconnect', async () => {
await startServer()
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await adapter.disconnectOnly()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
})
it('preserves a live session after clean detach and retires when that session exits', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await client.request('createOrAttach', { sessionId: 'preserved', cols: 80, rows: 24 })
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
})
client.disconnect()
expect(onIdleShutdown).not.toHaveBeenCalled()
subprocess.exit(0)
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
})
it('rejects clean retirement while create or attach is in flight', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as { createOrAttachInFlight: number }
daemon.createOrAttachInFlight = 1
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
})
expect(onIdleShutdown).not.toHaveBeenCalled()
client.disconnect()
})
it('rejects clean retirement when an unknown transport is connected', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const rawSocket = connect(socketPath)
await new Promise<void>((resolve) => rawSocket.once('connect', resolve))
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
})
rawSocket.destroy()
expect(onIdleShutdown).not.toHaveBeenCalled()
client.disconnect()
})
it('rejects clean retirement while another authenticated client is connected', async () => {
await startServer()
const first = new DaemonClient({ socketPath, tokenPath })
const second = new DaemonClient({ socketPath, tokenPath })
await Promise.all([first.ensureConnected(), second.ensureConnected()])
await expect(first.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
})
expect(onIdleShutdown).not.toHaveBeenCalled()
first.disconnect()
second.disconnect()
})
it("preserves another client's live session when failed adoption disconnects", async () => {
await startServer()
const adoptingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
const liveOwner = new DaemonClient({ socketPath, tokenPath })
await Promise.all([adoptingAdapter.establishLifecycleLease(), liveOwner.ensureConnected()])
await liveOwner.request('createOrAttach', {
sessionId: 'owned-by-second-client',
cols: 80,
rows: 24
})
await adoptingAdapter.disconnectOnly()
expect(onIdleShutdown).not.toHaveBeenCalled()
await expect(liveOwner.request('listSessions', undefined)).resolves.toMatchObject({
sessions: [expect.objectContaining({ sessionId: 'owned-by-second-client', isAlive: true })]
})
subprocess.exit(0)
liveOwner.disconnect()
})
it('lets an overlapping raw socket block but not erase empty retirement', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const rawSocket = connect(socketPath)
await new Promise<void>((resolve) => rawSocket.once('connect', resolve))
client.disconnect()
expect(onIdleShutdown).not.toHaveBeenCalled()
rawSocket.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
})
it('retires after the last authenticated client disconnects with no sessions', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(clock.pendingCount).toBe(0)
client.disconnect()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(clock.pendingCount).toBe(0)
})
it('lets a complete reconnect cancel retirement while a live session blocks it', async () => {
await startServer()
const first = new DaemonClient({ socketPath, tokenPath })
await first.ensureConnected()
await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 })
first.disconnect()
const daemon = server as unknown as { retirementRequested: boolean }
await waitFor(() => daemon.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
const second = new DaemonClient({ socketPath, tokenPath })
await second.ensureConnected()
await waitFor(() => !daemon.retirementRequested)
subprocess.exit(0)
expect(onIdleShutdown).not.toHaveBeenCalled()
second.disconnect()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('cancels the initial-adoption timeout before the adapter creates its first terminal', async () => {
await startServer()
await waitFor(() => clock.pendingCount === 1)
clock.advanceBy(50)
const adopted = new DaemonPtyAdapter({ socketPath, tokenPath })
await adopted.establishLifecycleLease()
expect(clock.pendingCount).toBe(0)
clock.advanceBy(100)
expect(onIdleShutdown).not.toHaveBeenCalled()
await expect(
adopted.spawn({
sessionId: 'first-after-adoption',
cols: 80,
rows: 24
})
).resolves.toMatchObject({ id: 'first-after-adoption' })
subprocess.exit(0)
adopted.dispose()
})
it('does not let repeated authenticated control probes extend the startup deadline', async () => {
await startServer()
const healthControl = connect(socketPath)
await new Promise<void>((resolve) => healthControl.once('connect', resolve))
healthControl.write(
`${JSON.stringify({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8').trim(),
clientId: 'startup-health-control',
role: 'control'
})}\n`
)
const daemon = server as unknown as { clients: Map<string, unknown> }
await waitFor(() => daemon.clients.has('startup-health-control'))
clock.advanceBy(1_000)
healthControl.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('does not let a control-only create cancel the initial-adoption timeout', async () => {
await startServer()
const control = connect(socketPath)
await new Promise<void>((resolve) => control.once('connect', resolve))
control.write(
`${JSON.stringify({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8').trim(),
clientId: 'startup-control-create',
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('startup-control-create'))
const response = await requestOnRawSocket(control, {
id: 'control-only-create',
type: 'createOrAttach',
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
})
expect(response.error).toContain('connection is incomplete')
expect(daemon.retirementRequested).toBe(false)
expect(clock.pendingCount).toBe(0)
clock.advanceBy(1_000)
expect(onIdleShutdown).not.toHaveBeenCalled()
control.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('keeps empty-retirement intent when a control-only client overlaps the last app', async () => {
await startServer()
const paired = new DaemonClient({ socketPath, tokenPath })
await paired.ensureConnected()
const incomplete = connect(socketPath)
await new Promise<void>((resolve) => incomplete.once('connect', resolve))
incomplete.write(
`${JSON.stringify({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8').trim(),
clientId: 'control-only-overlap',
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('control-only-overlap'))
paired.disconnect()
await waitFor(() => daemon.retirementRequested)
expect(clock.pendingCount).toBe(0)
const response = await requestOnRawSocket(incomplete, {
id: 'overlap-control-create',
type: 'createOrAttach',
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
})
expect(response.error).toContain('connection is incomplete')
expect(daemon.retirementRequested).toBe(true)
incomplete.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('keeps retirement intent when a same-client replacement never completes its stream', async () => {
await startServer()
const paired = new DaemonClient({ socketPath, tokenPath })
await paired.ensureConnected()
const clientId = (paired as unknown as { clientId: string }).clientId
const replacementControl = connect(socketPath)
await new Promise<void>((resolve) => replacementControl.once('connect', resolve))
replacementControl.write(
`${JSON.stringify({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8').trim(),
clientId,
role: 'control'
})}\n`
)
const daemon = server as unknown as {
retirementRequested: boolean
}
await waitFor(() => daemon.retirementRequested)
expect(clock.pendingCount).toBe(0)
replacementControl.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('keeps a live session after clients disconnect, then retires on its exit', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 })
client.disconnect()
const daemon = server as unknown as { retirementRequested: boolean }
await waitFor(() => daemon.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
expect(clock.pendingCount).toBe(0)
subprocess.exit(0)
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
})
it('uses the direct-construction protocol fixture version for hello compatibility', async () => {
await startServer({ protocolVersion: 22 })
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 22 })
await expect(client.ensureConnected()).resolves.toBeUndefined()
client.disconnect()
})
it('requests clean retirement only for protocol v24 and newer adapters', async () => {
await startServer()
const current = new DaemonPtyAdapter({ socketPath, tokenPath })
await current.listProcesses()
const currentClient = (
current as unknown as {
client: DaemonClient
}
).client
const currentRequest = vi.spyOn(currentClient, 'request')
await current.disconnectOnly()
expect(currentRequest).toHaveBeenCalledOnce()
expect(currentRequest.mock.calls[0]?.slice(0, 2)).toEqual(['shutdownIfIdle', undefined])
const timeoutMs = currentRequest.mock.calls[0]?.[2]
// Why: connection and RPC share a wall-clock budget, so elapsed setup time is expected.
expect(timeoutMs).toEqual(expect.any(Number))
expect(timeoutMs).toBeGreaterThan(0)
expect(timeoutMs).toBeLessThanOrEqual(250)
})
it('does not send the v24 clean-disconnect RPC to a legacy daemon', async () => {
await startServer({ protocolVersion: 23 })
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 })
await legacy.listProcesses()
const legacyClient = (
legacy as unknown as {
client: DaemonClient
}
).client
const legacyRequest = vi.spyOn(legacyClient, 'request')
await legacy.disconnectOnly()
expect(legacyRequest.mock.calls.map(([type]) => type)).not.toContain('shutdownIfIdle')
})
it('rejects create or attach once the idle admission fence is pending', async () => {
await startServer()
const daemon = server as unknown as {
idleShutdownState: string
routeRequest(clientId: string, request: unknown): Promise<unknown>
}
daemon.idleShutdownState = 'idle-shutdown-pending'
await expect(
daemon.routeRequest('late-client', {
id: 'late-create',
type: 'createOrAttach',
payload: { sessionId: 'late', cols: 80, rows: 24 }
})
).rejects.toThrow('temporarily unavailable; reconnect')
})
it('aborts the pending shutdown when create or attach started before the fence', async () => {
await startServer()
const daemon = server as unknown as {
createOrAttachInFlight: number
idleShutdownState: string
beginIdleShutdown(): void
}
daemon.createOrAttachInFlight = 1
daemon.beginIdleShutdown()
expect(daemon.idleShutdownState).toBe('running')
expect(onIdleShutdown).not.toHaveBeenCalled()
})
it('explicitly marks a post-fence accepted transport as retryable', async () => {
await startServer()
const daemon = server as unknown as {
idleShutdownState: string
handleConnection(socket: Socket): void
}
daemon.idleShutdownState = 'idle-shutdown-pending'
const socket = new EventEmitter() as Socket
socket.end = vi.fn() as unknown as Socket['end']
daemon.handleConnection(socket)
const payload = vi.mocked(socket.end).mock.calls[0]?.[0]
expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true })
socket.emit('close')
})
it('preserves a replacement PID record during otherwise successful idle cleanup', async () => {
writeFileSync(
pidPath,
serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce: 'replacement' })
)
await startServer({ launchNonce: 'mine' })
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
await client.request('shutdownIfIdle', undefined)
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
pid: process.pid,
launchNonce: 'replacement'
})
})
it('preserves a token file replaced before idle cleanup', async () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
writeFileSync(tokenPath, 'replacement-token')
await client.request('shutdownIfIdle', undefined)
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token')
})
})
describe('daemon PID record ownership cleanup', () => {
let dir: string
let pidPath: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-pid-ownership-'))
pidPath = join(dir, 'daemon.pid')
})
afterEach(() => rmSync(dir, { recursive: true, force: true }))
it('unlinks only an exact PID and launch nonce match', () => {
writeFileSync(
pidPath,
serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'mine' })
)
expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(true)
expect(existsSync(pidPath)).toBe(false)
})
it.each([
['malformed', '{'],
['stale PID', serializeDaemonPidFile({ pid: 456, startedAtMs: null, launchNonce: 'mine' })],
[
'replacement nonce',
serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'new' })
]
])('preserves a %s record', (_label, contents) => {
writeFileSync(pidPath, contents)
expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false)
expect(readFileSync(pidPath, 'utf8')).toBe(contents)
})
it('leaves a missing record missing', () => {
expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false)
expect(existsSync(pidPath)).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -4,9 +4,16 @@ import type { DaemonFileLog } from './daemon-file-log'
export type DaemonStartOptions = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
startedAtMs?: number
/** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */
protocolVersion?: number
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
log?: DaemonFileLog
onIdleShutdown?: () => void
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
}
export type DaemonHandle = {
@@ -17,9 +24,17 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
const server = new DaemonServer({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
...(opts.pidPath ? { pidPath: opts.pidPath } : {}),
...(opts.launchNonce ? { launchNonce: opts.launchNonce } : {}),
...(opts.startedAtMs ? { startedAtMs: opts.startedAtMs } : {}),
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
spawnSubprocess: opts.spawnSubprocess,
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
...(opts.log ? { log: opts.log } : {})
...(opts.log ? { log: opts.log } : {}),
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
...(opts.initialAdoptionTestConfig
? { initialAdoptionTestConfig: opts.initialAdoptionTestConfig }
: {})
})
await server.start()
@@ -0,0 +1,12 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
export const PROTOCOL_VERSION = 24
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
}
+97 -96
View File
@@ -42,8 +42,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
// Why: getCwd falls back to OS pid lookup; a plausible fake pid can
// collide with an unrelated local process and leak its cwd into tests.
// Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd.
pid: 999_999_999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
@@ -144,6 +143,72 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
it('carries classified startup spans from the daemon source to the adapter', async () => {
const onData = vi.fn()
adapter.onData(onData)
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
...(process.platform === 'win32'
? { echoProjection: 'windows-conpty-esc-stripped' as const }
: {})
}
})
const query = '\x1b]10;?\x07'
lastSubprocess._simulateData(query)
lastSubprocess._simulateData('prompt')
await waitFor(() => onData.mock.calls.length >= 2)
expect(lastSubprocess.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
expect(onData).toHaveBeenCalledWith({
id,
data: '',
sequenceChars: query.length,
seq: query.length,
transformed: true
})
expect(onData).toHaveBeenCalledWith({ id, data: 'prompt' })
await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({
data: expect.not.stringContaining(']10;rgb')
})
})
it('omits startup intent and close control for the preserved v23 protocol', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
isNew: true,
pid: null,
shellState: 'unsupported',
snapshot: null
} as never)
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 })
try {
await legacy.spawn({
sessionId: 'legacy-session',
cols: 80,
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
expect(createPayload).not.toHaveProperty('startupIngress')
await expect(legacy.closeStartupQueryAuthority('legacy-session')).resolves.toBe(0)
expect(requestSpy).not.toHaveBeenCalledWith('closeStartupQueryAuthority', expect.anything())
} finally {
legacy.dispose()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
it('uses worktreeId as session prefix when provided', async () => {
const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' })
expect(result.id).toContain('wt-1')
@@ -275,8 +340,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
adapter.pauseProducer(id)
await waitFor(() => lastSubprocess.pause.mock.calls.length > 0)
// Drop the daemon out from under the adapter: the in-flight pause has no
// matching resume anymore.
// Drop the daemon out from under the adapter: the in-flight pause now has no matching resume.
await server.shutdown()
await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected())
@@ -421,27 +485,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(await adapter.getAppliedSize(id)).toEqual({ cols: 120, rows: 40 })
})
// Why: this is the regression the fix targets. resize() is a fire-and-forget
// notify; a resize that arrives after the session exited is silently dropped
// daemon-side. getAppliedSize must keep reporting the last size the PTY
// genuinely took (the stale pre-exit dims) rather than the dropped request —
// so the renderer can tell its requested size never landed. The old
// requested-size cache would have masked the drop.
// Why: a resize after exit is a dropped fire-and-forget notify; getAppliedSize must report the PTY's last real size, not the drop.
it('does not advance when a resize is dropped after the session exited', async () => {
const { id } = await adapter.spawn({ cols: 200, rows: 50 })
// Simulate the child exiting, then a late narrow resize racing in. The
// daemon Session.resize early-returns for an exited session, so the child
// is never resized and the applied size never becomes the requested 80×24.
// Child exits, then a late narrow resize races in; daemon Session.resize early-returns for an exited session so 80×24 never lands.
lastSubprocess._simulateExit(0)
await new Promise((r) => setTimeout(r, 50))
adapter.resize(id, 80, 24)
await new Promise((r) => setTimeout(r, 50))
// The drop must be visible: the subprocess was never resized to the narrow
// dims the renderer requested, and getAppliedSize never reports 80 cols —
// it stays wide (or null once reaped), never masking the drop as "applied".
// The drop must stay visible: never resized to 80 cols, and getAppliedSize never reports 80 (stays wide, or null once reaped).
expect(lastSubprocess.resize).not.toHaveBeenCalledWith(80, 24)
const applied = await adapter.getAppliedSize(id)
expect(applied?.cols).not.toBe(80)
@@ -479,8 +534,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(lastSubprocess.forceKill).toHaveBeenCalled()
})
// Why: shutdown can be the first lazy-client operation after restart; it
// must connect before killing so a healthy session is not orphaned (#7742).
// Why: shutdown can be the first lazy-client op after restart; connect before killing or a healthy session is orphaned (#7742).
it('kills a live session from a fresh adapter that has not connected yet', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
@@ -517,9 +571,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
describe('sessionsNeedingFullCheckpoint cleanup (leak regression)', () => {
// Why: the cold-restore path flags a session for a full checkpoint. If the
// session exits before that checkpoint lands, the flag was never cleared and
// leaked a permanent Set entry for the daemon's lifetime.
// Why: cold-restore flags a session for a full checkpoint; exiting before it lands leaked a permanent Set entry for the daemon's life.
it('clears the pending full-checkpoint flag when a session exits', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const internals = adapter as unknown as { sessionsNeedingFullCheckpoint: Set<string> }
@@ -768,11 +820,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('evicts oldest tombstone when exceeding limit', async () => {
// Why: MAX_TOMBSTONES is 1000, but spawning that many real sessions is
// slow. Instead verify the eviction logic by spawning a small batch and
// checking the oldest tombstone is gone after crossing the cap. We access
// the private map size via the public API: the oldest session should
// become spawnable again once evicted.
// Why: MAX_TOMBSTONES is 1000; spawning that many is slow, so verify eviction with a small batch via the public spawn API.
const ids: string[] = []
for (let i = 0; i < 5; i++) {
const id = `evict-${i}`
@@ -793,8 +841,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })
await adapter.shutdown(ids[0], { immediate: true })
// First tombstone was re-added at the end of the Map, so eviction
// order is now [evict-1, evict-2, evict-3, evict-4, evict-0]
// First tombstone was re-added at the Map's end, so eviction order is now [evict-1, evict-2, evict-3, evict-4, evict-0]
await expect(adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })).rejects.toThrow(
'was explicitly killed'
)
@@ -843,13 +890,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('kills sessions whose id does not match the minted format, even if id is in valid set', async () => {
// Why: parsePtySessionId rejects bare UUIDs (no `@@`) and ids without
// the `::` worktree shape. Such sessions can't be attributed to any
// current worktree and must be treated as orphans regardless of
// valid-set membership. Passing the bare-uuid as a member of
// validWorktreeIds proves the new strict parser short-circuits the
// membership check — under the old loose parser this session would
// have been kept.
// Why: parsePtySessionId rejects ids with no worktree shape, so they're orphaned regardless of valid-set membership.
const sessionId = 'bare-uuid-no-separators'
await adapter.spawn({ cols: 80, rows: 24, sessionId })
@@ -897,8 +938,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('hello from pty\r\n')
await new Promise((r) => setTimeout(r, 50))
// Why: checkpoint-based persistence does not write on every data event.
// No scrollback.bin should exist — checkpoints write checkpoint.json on a timer.
// Why: checkpoint-based persistence writes checkpoint.json on a timer, never scrollback.bin per data event.
expect(existsSync(join(historyDir, getHistorySessionDirName(id), 'scrollback.bin'))).toBe(
false
)
@@ -922,8 +962,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await new Promise((r) => setTimeout(r, 80))
// Why: idle terminals can be numerous. A periodic pass with no data
// must not serialize every live daemon session just because it exists.
// Why: idle terminals can be numerous; a periodic pass with no data must not serialize every live session.
expect(appendSpy).not.toHaveBeenCalled()
lastSubprocess._simulateData('new output\r\n')
@@ -931,8 +970,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [
{ kind: 'output', data: 'new output\r\n' }
])
// Why: the periodic tick must persist increments, never re-serialize
// the full emulator buffer (the issue #5096 stall).
// Why: the periodic tick must persist increments, never re-serialize the full emulator buffer (the issue #5096 stall).
expect(checkpointSpy).not.toHaveBeenCalled()
const logPath = join(historyDir, getHistorySessionDirName(id), 'output.log')
await waitFor(() => {
@@ -1059,15 +1097,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set(['hot']))
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
// Second tick inside the cooldown: the overflow defers and flags the
// session; no snapshot write.
// Second tick inside the cooldown: the overflow defers and flags the session; no snapshot write.
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set())
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
expect(internals.sessionsNeedingFullCheckpoint.has('hot')).toBe(true)
const requestsAfterSecondTick = internals.client.request.mock.calls.length
// Ticks 3..24 (a hot session over ~2 minutes): flagged + cooling down
// short-circuits with ZERO daemon RPCs and zero disk writes.
// Ticks 3..24 (a hot session over ~2 minutes): flagged + cooling down short-circuits with zero daemon RPCs and zero disk writes.
for (let i = 0; i < 22; i++) {
await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set())
}
@@ -1086,8 +1122,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await internals.checkpointSessions(['hot'])
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
// Cooldown is active, but quit/sleep-time persistence must not be
// deferred — stale-on-crash is acceptable, stale-on-clean-exit is not.
// Cooldown is active, but quit/sleep persistence must not defer — stale-on-crash is acceptable, stale-on-clean-exit is not.
await expect(internals.checkpointSessions(['hot'], { final: true })).resolves.toEqual(
new Set(['hot'])
)
@@ -1231,8 +1266,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json')
const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')
// Why: keep-history sleep stays unclean so cold restore remains eligible;
// the final checkpoint is the deterministic handoff signal.
// Why: keep-history sleep stays unclean so cold restore stays eligible; the final checkpoint is the deterministic handoff signal.
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
'first cycle content'
@@ -1466,11 +1500,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('cold-restores an alt-screen agent snapshot as scrollback on wake (hibernation)', async () => {
// Why: agent hibernation force-kills Claude/Codex while still in their
// alt-screen TUI, so scrollbackAnsi is empty. The fix falls back to the
// saved snapshot so the pane repaints the agent's last frame instead of
// coming back blank. (The payload is snapshotAnsi alone — no
// rehydrateSequences — so it never re-enters alt-screen.)
// Why: hibernation force-kills the agent in alt-screen (empty scrollbackAnsi); fall back to the snapshot so the pane repaints instead of blanking.
const sessionId = 'cold-restore-alt-screen'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
@@ -1512,14 +1542,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore).toBeDefined()
expect(result.coldRestore!.scrollback).toContain('Claude Code')
// The payload must NOT re-enter alt-screen — that would fight the
// relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET.
// The payload must NOT re-enter alt-screen — it would fight the relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET.
expect(result.coldRestore!.scrollback).not.toContain('\x1b[?1049h')
})
it('skips cold restore for an alt-screen session with an empty snapshot', async () => {
// Why: alt-screen entered before any content → nothing to show. Keep the
// no-op (blank) rather than fabricate a payload.
// Why: alt-screen entered before any content → nothing to show; keep the no-op rather than fabricate a payload.
const sessionId = 'cold-restore-alt-screen-empty'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
mkdirSync(sessionDir, { recursive: true })
@@ -1595,10 +1623,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('revived session output\r\n')
await waitFor(() => checkpointSpy.mock.calls.length === 1)
// Why: appending the fresh session's records to the pre-crash log
// would be rejected by the sequence check on a second crash, reverting
// the restore to pre-crash content. The full checkpoint resets the log
// to a new generation.
// Why: appending fresh records to the pre-crash log would fail the sequence check on a second crash; a full checkpoint resets the log to a new generation.
expect(appendSpy).not.toHaveBeenCalled()
expect(checkpointSpy).toHaveBeenCalledWith(
sessionId,
@@ -1637,14 +1662,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastFullCheckpointAt: Map<string, number>
sessionsNeedingFullCheckpoint: Set<string>
}
// A daemon respawn inside one adapter keeps this map: seed a fresh
// cooldown as if the pre-crash generation just snapshotted.
// A daemon respawn inside one adapter keeps this map, so seed a fresh cooldown as if the pre-crash generation just snapshotted.
internals.lastFullCheckpointAt.set(sessionId, Date.now())
await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
// The revived generation has no checkpoint of its own — the re-anchor
// must not inherit the previous generation's cooldown.
// The revived generation has no checkpoint of its own — the re-anchor must not inherit the previous generation's cooldown.
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
})
@@ -1655,10 +1678,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await first.spawn({ cols: 80, rows: 24, sessionId })
first.dispose()
// A fresh adapter (app relaunch) attaches to the still-live daemon
// session. The old adapter may have drained records it never persisted
// (deferred hot-session tick), so appends must not resume until a full
// snapshot re-anchors the log.
// The old adapter may have drained records it never persisted (deferred hot-session tick), so appends must wait for a full snapshot to re-anchor the log.
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const internals = historyAdapter as unknown as {
sessionsNeedingFullCheckpoint: Set<string>
@@ -1674,9 +1694,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const sessionId = 'warm-reattach-skip-replay'
const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId })
// Why disconnectOnly: the production app-quit path leaves meta.endedAt
// null so the session stays crash-recoverable — the state every app
// relaunch with a live daemon sees.
// Why disconnectOnly: the app-quit path leaves meta.endedAt null, keeping the session crash-recoverable like every relaunch with a live daemon.
await first.disconnectOnly()
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
@@ -1734,12 +1752,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
).client
const originalRequest = client.request.bind(client)
// Why: simulates the probe→createOrAttach race — the probe sees the
// session alive, but it is gone by the time createOrAttach runs. The
// meta rewrite mimics the dying session's exit event beating the
// createOrAttach reply and writing endedAt via closeSession; the
// fallback detect must still restore instead of falling through to
// openSession (which would delete the checkpoint).
// Why: simulates the probe→createOrAttach race (session dies mid-call, writing endedAt); fallback detect must still restore, not fall through to openSession which deletes the checkpoint.
vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
if (type === 'getSize') {
return { size: { cols: 100, rows: 30 } }
@@ -1765,8 +1778,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.coldRestore).toBeDefined()
expect(result.coldRestore!.scrollback).toContain('raced output')
// The unseeded race winner is replaced before exposure, so the retained
// shell uses the recovered dimensions as well as the recovered history.
// The unseeded race winner is replaced before exposure, so the retained shell uses recovered dimensions as well as history.
expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 100, rows: 30 })
// The recovery data must survive — openSession would have deleted it.
expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true)
@@ -1802,8 +1814,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
).client
const originalRequest = client.request.bind(client)
// Why: an old daemon rejects the unknown getSize method; the spawn must
// behave exactly like the unprobed path.
// Why: an old daemon rejects the unknown getSize method; the spawn must behave exactly like the unprobed path.
vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => {
if (type === 'getSize') {
return Promise.reject(new Error('Unknown request type'))
@@ -1939,9 +1950,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await new Promise((r) => setTimeout(r, 50))
// Why: checkpoint-based persistence opens the session for future
// checkpointing but does not seed scrollback.bin. New data is persisted
// via periodic checkpoint timer, not per-chunk appendData.
// Why: checkpoint-based persistence doesn't seed scrollback.bin — new data lands via the periodic checkpoint timer, not per-chunk appendData.
const meta = JSON.parse(
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'meta.json'), 'utf-8')
)
@@ -2118,10 +2127,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
it('treats a hello handshake timeout as daemon-gone and respawns (#8689)', async () => {
// Why: a wedged daemon accepts the socket connection but never answers
// hello, so ensureConnected() rejects with "Hello response timed out".
// That must be classified as daemon-gone so withDaemonRetry respawns and
// retries — otherwise every terminal spawn fails against the wedge forever.
// Why: a wedged daemon accepts the socket but never answers hello; classify as daemon-gone so withDaemonRetry respawns, else every spawn fails forever.
const realEnsureConnected = DaemonClient.prototype.ensureConnected
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
@@ -2289,12 +2295,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
// Why: the restart flow (docs/daemon-staleness-ux.md §Phase 1 step 1) relies
// on these two primitives to fan synthetic pty:exit out to every attached
// session *before* tearing the adapter down. The design doc calls out
// session.ts:246-252 as the reason — the daemon's kill-all-and-shutdown
// explicitly does NOT fan exits back through onExit. Without the fanout the
// renderer would black-hole writes against a disposed adapter.
// Why: daemon kill-all-and-shutdown doesn't fan exits through onExit, so these primitives synthesize pty:exit before dispose, else the renderer black-holes writes (docs/daemon-staleness-ux.md §Phase 1).
describe('fanoutSyntheticExits / getActiveSessionIds (restart primitives)', () => {
it('reports every live spawn in getActiveSessionIds', async () => {
const { id: id1 } = await adapter.spawn({ cols: 80, rows: 24 })
+232 -298
View File
@@ -1,6 +1,4 @@
/* oxlint-disable max-lines -- Why: history error-logging .catch() chains add ~10 lines of
safety wiring spread across spawn/event-routing; splitting would scatter tightly coupled
adapter ↔ history lifecycle logic. */
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import { DaemonClient } from './client'
@@ -11,8 +9,10 @@ import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsPtyStartupIngress,
type CreateOrAttachResult,
type DaemonEvent,
type GetSnapshotResult,
@@ -45,8 +45,7 @@ type ColdRestorePayload = {
}
function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
// Why: alt-screen snapshots represent the TUI buffer; prefer its normal
// scrollback so a dead TUI is not revived as the fresh shell's active screen.
// Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen.
return restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
@@ -67,17 +66,22 @@ export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
protocolVersion?: number
/** Directory for disk-based terminal history. When set, the adapter writes
* raw PTY output to disk for cold restore on daemon crash. */
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
historyPath?: string
/** Called when the daemon socket is unreachable (process died). Expected to
* fork a fresh daemon so the next connection attempt can succeed. */
respawn?: () => Promise<void>
/** Called when the daemon socket is unreachable; forks a fresh daemon so the next connect can succeed. */
respawn?: () => Promise<void | (() => void)>
}
const MAX_TOMBSTONES = 1000
const MAX_CONCURRENT_CHECKPOINTS = 4
// Why: providers take an absolute teardown deadline, but the client RPC takes a
// relative timeout — convert only here, at the request itself, so sequential RPCs
// naturally share the remaining budget (undefined keeps the client's 30s default).
function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | undefined {
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
}
export class TerminalKilledError extends Error {
constructor(sessionId: string) {
super(`Session "${sessionId}" was explicitly killed`)
@@ -92,69 +96,49 @@ export class DaemonPtyAdapter implements IPtyProvider {
private client: DaemonClient
private historyManager: HistoryManager | null
private historyReader: HistoryReader | null
private respawnFn: (() => Promise<void>) | null
// Why: multiple pane mounts can call spawn() concurrently. If the daemon is
// dead, all calls enter withDaemonRetry's catch block at once. Without a
// lock, each would fork its own daemon process. This promise coalesces
// concurrent respawns so only the first caller forks; the rest await it.
private respawnFn: (() => Promise<void | (() => void)>) | null
private pendingRespawnAdoptionRelease: (() => void) | null = null
private respawnAdoptionClosed = false
// Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it.
private respawnPromise: Promise<void> | null = null
private dataListeners: ((payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
private removeEventListener: (() => void) | null = null
private initialCwds = new Map<string, string>()
private wslDistrosBySessionId = new Map<string, string>()
// Why: React re-renders and StrictMode double-mounts can call createOrAttach
// for a session the user just killed. Without tombstones, the daemon would
// create a fresh session — resurrecting a terminal the user explicitly closed.
// Uses a Map<id, timestamp> so eviction removes the oldest by insertion order,
// matching terminal-host.ts tombstone semantics.
// Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts).
private killedSessionTombstones = new Map<string, number>()
// Why: React StrictMode double-mounts: mount → cold restore → unmount →
// mount → ??? The sticky cache returns the same cold restore data on the
// second mount until the renderer explicitly acknowledges it.
// Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it.
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
// log belong to the pre-crash session. Incremental appends would land on
// that stale log (and be rejected by its sequence check on restore), so the
// first tick must re-anchor with a full snapshot checkpoint, which resets
// the log to a new generation.
// Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot.
private sessionsNeedingFullCheckpoint = new Set<string>()
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
private checkpointInFlight: Promise<void> | null = null
// Why: checkpoint-based persistence requires the getSnapshot RPC (v4+).
// Legacy daemons reject it, causing noisy log spam every 5 seconds.
// Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s.
private supportsCheckpoints: boolean
// Why: incremental checkpoints require the takePendingOutput RPC (v13+).
// Against older daemons the tick falls back to full-snapshot checkpoints.
// Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints.
private supportsIncrementalCheckpoints: boolean
// Why: producer pause/resume notifications require v19+; legacy daemons
// must never see them, so gating makes them silent no-ops there.
// Why: producer pause/resume notifications require v19+; gate them to silent no-ops on legacy daemons.
private supportsProducerFlowControl: boolean
private supportsAuthoritativeBufferSnapshots: boolean
private supportsStartupIngress: boolean
private pausedProducerSessionIds = new Set<string>()
// Why tracked here: the daemon's background set (keep-tail stream thinning
// + transient-fact scan authority) dies with the daemon process/socket;
// re-sync it on a fresh connection so hidden panes stay thinned.
// Why tracked here: the daemon's background set dies with the daemon process/socket; re-sync on a fresh connection so hidden panes stay thinned.
private backgroundedSessionIds = new Set<string>()
// Why: a daemon that survives a socket drop can still hold a pause whose
// resume died with the connection. Owe those sessions a resume on the next
// connect; the daemon's 5s failsafe covers the window in between.
// Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap).
private producerResumesOwedOnReconnect = new Set<string>()
private static CHECKPOINT_INTERVAL_MS = 5_000
// Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB
// snapshot checkpoint on every 5s tick via pending-buffer overflow or the
// log-size cap — hundreds of MB/min of disk writes from one busy terminal.
// Bounding cap/overflow-triggered snapshots per session trades bounded
// cold-crash scrollback staleness (warm reattach and final checkpoints are
// unaffected and bypass this) for a ~9x cut in worst-case write volume.
// Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness).
private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
private lastFullCheckpointAt = new Map<string, number>()
@@ -182,6 +166,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20
this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion)
this.client.onDisconnected(() => {
for (const id of this.pausedProducerSessionIds) {
this.producerResumesOwedOnReconnect.add(id)
@@ -232,22 +217,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
await this.ensureConnected()
// Why before createOrAttach: a preserved v19 daemon may remember this
// session as backgrounded. Ordered control delivery clears it before any
// newly attached stream bytes can be thinned without a recoverable seq.
// Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq.
if (!this.supportsAuthoritativeBufferSnapshots) {
this.setPtyBackgrounded(sessionId, false)
}
// Why: detect crash-recovery history before spawning a replacement PTY so
// the revived shell inherits the recovered cwd and dimensions instead of
// whatever the current renderer happened to request on mount.
// Why probe aliveness first: detectColdRestore synchronously replays the
// full checkpoint + log (up to ~5MB) through a scratch emulator on the
// main process, but a live daemon session ignores spawn params and its
// own snapshot supersedes disk — the replay result would be discarded.
// getSize is a read-only probe; on error/unsupported it degrades to the
// full detect.
// Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request.
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
if (this.historyReader?.hasRestorableHistory(sessionId)) {
@@ -286,24 +262,22 @@ export class DaemonPtyAdapter implements IPtyProvider {
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
launchAgent: opts.launchAgent,
// Why: without this, the daemon always spawns cmd.exe (COMSPEC) or
// PowerShell as a fallback — regardless of which shell the renderer
// asked for in the "+" menu or persisted as the default. Forwarding
// the override makes the daemon path behave the same as the in-process
// LocalPtyProvider.
// Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider.
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation,
shellReadySupported,
...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}),
...(historySeed ? { historySeed } : {})
...(historySeed ? { historySeed } : {}),
...(this.supportsStartupIngress && opts.startupIngress
? { startupIngress: opts.startupIngress }
: {})
})
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
let result = await createOrAttach(scrollback)
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
// Why: explicit null from a current daemon overrides the caller's WSL
// preference; undefined preserves compatibility with older daemons.
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
wslDistro = providerWslDistro ?? undefined
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
@@ -317,20 +291,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.initialCwds.set(sessionId, effectiveCwd)
}
// Why: the daemon RPC returns the shell pid of the backing subprocess.
// Surfacing it through PtySpawnResult lets ipc/pty register with the
// memory collector without a provider-specific accessor.
// Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor.
let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
// Why: check sticky cache first — StrictMode double-mounts call spawn
// twice. The second call finds an existing daemon session (isNew=false)
// but should still return the cached cold restore data.
// Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data.
const cachedRestore = this.coldRestoreCache.get(sessionId)
if (cachedRestore) {
// Why: wake after sleep also lands here, and the slept session's active
// tracking and history writer were dropped when sleep killed the PTY.
// Without re-registering both, checkpoints stop after wake and the
// second sleep/wake cycle restores a blank terminal.
// Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal.
this.activeSessionIds.add(sessionId)
if (this.historyManager) {
this.historyManager.reopenSession(sessionId)
@@ -345,20 +312,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
// Why: the probe→createOrAttach gap is racy — the session can exit (or
// enter termination) in between, so the daemon spawned a fresh shell.
// Detect now so scrollback restore matches the unprobed path; only the
// new shell's cwd/dims came from the renderer request in this rare case.
// Why ignoreCleanEnd: the raced session's exit event (stream socket) can
// beat the createOrAttach reply and write endedAt via closeSession; that
// must not null the restore here, or the openSession branch below would
// delete the checkpoint instead of restoring it.
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (result.isNew && restoreSkippedForLiveSession) {
restoreInfo = detectColdRestore({ ignoreCleanEnd: true })
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
if (restoreInfo && scrollback) {
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
// Why: the aliveness probe raced with session death, so the first create lacked recovery bytes; replace it before exposing the PTY.
await this.client.request('kill', { sessionId, immediate: true })
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
@@ -383,26 +343,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceForSpawn(result)
// Cold restore: daemon created a new session but disk history shows
// an unclean shutdown → return saved scrollback so the renderer can
// display the previous terminal content.
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
const coldRestore = this.buildColdRestorePayload(restoreInfo)
const canReanchorHistory = !scrollback || result.historySeeded === true
// Why: use registerWriter (not openSession) to avoid deleting the
// existing checkpoint.json. If the revived daemon crashes again before
// the next 5s tick, the checkpoint is the only recovery data available.
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
if (this.historyManager) {
if (canReanchorHistory) {
this.historyManager.registerWriter(sessionId)
this.sessionsNeedingFullCheckpoint.add(sessionId)
// Why: the revived generation has no valid checkpoint of its own; a
// cooldown inherited from the pre-crash generation (daemon respawn
// within one adapter) must not defer this re-anchor.
// Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor.
this.lastFullCheckpointAt.delete(sessionId)
} else {
// Preserve the old recovery files when the new daemon cannot include
// them; a fresh-only checkpoint would make the data loss permanent.
// Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent.
this.historyManager.suspendSession(sessionId)
}
}
@@ -436,21 +389,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
.catch((err) => console.warn('[history] openSession failed:', sessionId, err))
} else if (this.historyManager && result.historySeeded === false) {
// Why: the daemon keeps this failure bit with the live session, so a new
// adapter cannot promote its fresh-only snapshot after an app restart.
// Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart.
this.historyManager.suspendSession(sessionId)
} else if (this.historyManager) {
// Why: on warm reattach after app relaunch, the HistoryManager is a
// fresh instance with no writers. registerWriter adds the writer
// without overwriting meta.json or deleting the existing checkpoint
// (which is the only valid recovery data until the next tick).
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
this.historyManager.registerWriter(sessionId)
if (!wasAlreadyManaged) {
// Why: a previous adapter may have drained daemon records it never
// persisted (a deferred hot-session tick) before the app died.
// Appending increments past that unknown drain point would put a seq
// gap in the log, which the restore reader rejects wholesale. Force a
// full snapshot to re-anchor before any further appends.
// Why: a previous adapter may have drained records it never persisted, so appending would leave a seq gap the reader rejects; force a full snapshot to re-anchor.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
@@ -473,10 +418,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
result.snapshot.scrollbackAnsi +
result.snapshot.rehydrateSequences +
result.snapshot.snapshotAnsi
// Why kitty flags ride beside the payload, not inside it: the snapshot
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
// deliberate kitty reset must win. Only the runtime emulator re-seed
// consumes the flags (terminal-query-authority.md §kitty).
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
id: sessionId,
@@ -492,9 +434,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
: {}),
isReattach: true,
isAlternateScreen: isAltScreen,
// Why: carry the mid-escape tail so the renderer can write it after the
// reattach reset — without it the local daemon reattach path renders a
// split escape's continuation literally, unlike the remote path (#7329).
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
...(result.snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: result.snapshot.pendingEscapeTailAnsi }
: {})
@@ -545,14 +485,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.notify('resumePty', { sessionId: id })
}
// Why fire-and-forget (like pausePty): a delivery hint for the daemon's
// keep-tail stream thinning.
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
setPtyBackgrounded(id: string, background: boolean): void {
if (!this.supportsProducerFlowControl) {
return
}
// Why: preserved v19 daemons can thin but cannot return the absolute
// snapshot sequence needed to recover a gap. Clear their stale hint too.
// Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too.
const safeBackground = this.supportsAuthoritativeBufferSnapshots && background
if (safeBackground) {
this.backgroundedSessionIds.add(id)
@@ -562,10 +500,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
// Why: shutdown can be the first lazy-client operation after restart; connect
// before killing so a healthy daemon session is not orphaned (#7742).
await this.ensureConnected()
// before killing so a healthy daemon session is not orphaned (#7742). Connect
// and kill share the caller's one absolute deadline, so a wedged handshake
// cannot burn the whole teardown budget before the kill even starts.
await this.ensureConnected(opts.deadlineMs)
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
// Force a final snapshot so wake can restore the pane users left.
if (opts.keepHistory) {
@@ -590,40 +533,35 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
this.sleepRestoreSessionIds.add(id)
// Why: physical exit must not mark intentional sleep as a clean end;
// the final checkpoint remains the wake-time recovery authority.
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
this.historyManager?.suspendSession(id)
}
}
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
await this.client.request(
'kill',
{ sessionId: id, immediate: opts.immediate ?? false },
remainingRequestTimeoutMs(opts.deadlineMs)
)
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
if (!opts.keepHistory) {
this.coldRestoreCache.delete(id)
this.sleepRestoreSessionIds.delete(id)
}
// Why: the !keepHistory close path doesn't take a final checkpoint, so a
// session stranded in sessionsNeedingFullCheckpoint would never be cleared.
// (Under keepHistory the final checkpoint above already cleared the flag, so
// this is a harmless no-op there — kept unconditional to cover both paths.)
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
this.sessionsNeedingFullCheckpoint.delete(id)
this.lastFullCheckpointAt.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
this.wslDistrosBySessionId.delete(id)
// Why: history removal is for the "user explicitly closed this terminal"
// path. Sleep also calls shutdown but expects scrollback to survive — wake
// re-spawns and the cold-restore reader needs the dir intact. Caller
// indicates intent via opts.keepHistory.
// Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory).
if (this.historyManager && !opts.keepHistory) {
void this.historyManager
.removeSession(id)
.catch((err) => console.warn('[history] removeSession failed:', id, err))
}
// Why: tombstone rejects reattach against a session the user explicitly
// killed. Sleep legitimately reattaches on wake, so skip both the LRU bump
// and the size-cap eviction under keepHistory.
// Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory.
if (!opts.keepHistory) {
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
@@ -646,13 +584,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
// (no rehydrateSequences — they start with \x1b[?1049h, which the
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
// normal scrollback. An empty snapshot still yields null → no-op.
// Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake.
const scrollback = restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
@@ -687,12 +619,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.initialCwds.get(id) ?? ''
}
// Why: resize() is a fire-and-forget notify, so a resize can be dropped
// daemon-side (session not yet alive, exited, invalid dims, cold-restore
// snapshot-col coercion) without the renderer knowing. This reads the size
// the daemon actually applied so the renderer can detect that drift on resume
// and re-assert. Null (RPC failure / unknown session) means "cannot confirm",
// which the renderer treats as a cue to re-forward once.
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
@@ -718,8 +645,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons have no absolute output sequence. Their snapshot
// cannot safely reconcile stream bytes still queued on the other socket.
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
@@ -754,8 +680,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
async hasChildProcesses(id: string): Promise<boolean> {
const foregroundProcess = await this.getForegroundProcess(id)
// Why: daemon-backed PTYs can host long-lived agents while the renderer is
// detached. Cleanup prompts must not treat those sessions like idle shells.
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
@@ -795,16 +720,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Sessions already live in the daemon — no revival needed
}
/** Called on app launch. Lists daemon sessions, kills orphans whose
* workspaceId no longer exists, and caches alive session IDs.
/** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId
* no longer exists, and caches alive session IDs.
*
* IMPORTANT: a session id embeds the worktree id it was minted under, which is
* the worktree's *path* at spawn time. When a worktree folder is renamed, its
* id changes but live sessions keep the old id. Callers MUST therefore seed
* `validWorktreeIds` with each live worktree's `WorktreeMeta.priorWorktreeIds`
* (the pre-rename aliases) or those sessions will be reaped as false orphans.
* This reconcile has no production caller yet; wire the alias in when it gains
* one. */
* IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed
* worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live
* worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false
* orphans. No production caller yet; wire the alias in when it gains one. */
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
@@ -819,9 +741,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!session.isAlive) {
continue
}
// Why: session IDs use the format `${worktreeId}@@${shortUuid}`. Sessions
// whose id does not match the minted format (worktreeId === null) cannot
// be tied to a live worktree and are treated as orphans.
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
@@ -833,10 +753,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
killed.push(session.sessionId)
} else {
alive.push(session.sessionId)
// Why: background sessions discovered here may produce output before
// the user reattaches their pane. Without adding them to the checkpoint
// set, disconnectOnly()'s final checkpoint would skip them, leaving
// stale recovery data if the daemon later crashes.
// Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data.
this.activeSessionIds.add(session.sessionId)
this.historyManager?.registerWriter(session.sessionId)
}
@@ -845,25 +762,27 @@ export class DaemonPtyAdapter implements IPtyProvider {
return { alive, killed }
}
async listProcesses(): Promise<PtyProcessInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
// Why: connect + listSessions share the caller's one absolute deadline so a
// wedged handshake cannot burn the whole teardown budget before the list issues.
await this.ensureConnected(opts?.deadlineMs)
const result = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingRequestTimeoutMs(opts?.deadlineMs)
)
return result.sessions
.filter((s) => s.isAlive)
.map((s) => ({
id: s.sessionId,
// Why: OSC 7 may not arrive before destructive cleanup. Spawn cwd is
// still authoritative ownership until the daemon reports a live cwd.
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
title: 'shell',
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}))
}
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
// createdAt) per session for display; listProcesses drops that detail for
// the IPtyProvider contract. Keep both in parallel rather than widening
// the provider surface.
// Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract.
async listSessions(): Promise<SessionInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
@@ -874,13 +793,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
return [...this.activeSessionIds]
}
// Why: used by the "Restart daemon" handler to synthesize pty:exit for every
// live session *before* tearing down the adapter. The daemon's own
// kill-all-and-shutdown path explicitly suppresses onExit fanout
// (session.ts:246-252), so without this the renderer panes would black-hole
// writes to a disposed adapter forever. Reuses the existing exitListeners
// path so downstream cleanup (clearProviderPtyState, markClaudePtyExited,
// renderer pty:exit) runs exactly as it does on natural exit.
// Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit
// for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever.
fanoutSyntheticExits(code: number): void {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
@@ -892,10 +806,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.stopCheckpointTimer()
for (const id of ids) {
this.coldRestoreCache.delete(id)
// Why: listener throws are intentionally *not* caught — matches the
// natural onExit fanout in setupEventRouting, so synthetic exits don't
// diverge in error semantics from real ones. A throwing listener is a
// bug that should surface loudly, not be silently swallowed.
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id, code })
@@ -922,7 +833,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
onData(
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
this.dataListeners.push(callback)
return () => {
@@ -958,6 +875,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
dispose(): void {
this.respawnAdoptionClosed = true
this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
@@ -967,9 +886,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.producerResumesOwedOnReconnect.clear()
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side in TerminalHost.dispose()
// which has direct access to sessions. The adapter only marks sessions as
// cleanly ended here so they don't trigger false cold restores.
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
// cleanly ended so they don't trigger false cold restores.
if (this.historyManager) {
void this.historyManager
.dispose()
@@ -978,32 +896,32 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.disconnect()
}
// Why: for in-process daemon mode, disconnect without flushing history.
// dispose() writes endedAt for all sessions, which would prevent cold
// restore. disconnectOnly() leaves history files in unclean state so
// the next launch detects them as crash-recoverable.
// We write a final checkpoint before disconnecting so that if the daemon
// later crashes while Orca is closed, checkpoint.json has recovery data.
async establishLifecycleLease(): Promise<void> {
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
return
}
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
await this.client.ensureConnected()
}
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
async disconnectOnly(): Promise<void> {
this.respawnAdoptionClosed = true
this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
// Why: wait for any in-flight timer pass to finish before starting
// the final checkpoint. Otherwise both passes race on the shared tmp
// file, risking ENOENT on rename and disabling future writes.
// Why: wait out an in-flight timer pass; racing on the shared tmp file risks ENOENT on rename, disabling future writes.
if (this.checkpointInFlight) {
await this.checkpointInFlight
}
// Why: without a final checkpoint, sessions opened after the last timer
// tick have no checkpoint.json on disk. If the detached daemon later
// dies, detectColdRestore finds nothing to restore from. Must await
// before disconnecting — fire-and-forget would race with client.disconnect()
// and the pending getSnapshot RPCs would be rejected.
// Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon
// later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs.
await this.checkpointAllSessions()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
// Why: the detached daemon keeps these PTYs alive for warm reattach; a
// pause left behind would block their shells for a failsafe window.
// Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window.
for (const id of this.pausedProducerSessionIds) {
this.client.notify('resumePty', { sessionId: id })
}
@@ -1011,15 +929,34 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.producerResumesOwedOnReconnect.clear()
this.removeEventListener?.()
this.removeEventListener = null
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
try {
// Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path.
const deadlineMs = Date.now() + 250
if (!this.client.isConnected()) {
await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
}
await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now()))
} catch {
// An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty.
}
}
this.client.disconnect()
}
private async ensureConnected(): Promise<void> {
await this.client.ensureConnected()
// Why sampled before setupEventRouting: routing is (re)installed exactly
// once per connection, so "no listener yet" identifies a fresh connect —
// the only time the daemon-side backgrounded set needs a resync (it is
// process state that died with the previous daemon/socket).
private async ensureConnected(deadlineMs?: number): Promise<void> {
try {
// Why: destructive teardown bounds the handshake by its deadline so a wedged
// connect fails fast; undefined keeps the default connect behavior.
await (deadlineMs !== undefined
? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
: this.client.ensureConnected())
} finally {
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
this.releasePendingRespawnAdoptionLease()
}
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
const isFreshConnection = this.removeEventListener === null
this.setupEventRouting()
this.scheduleCheckpointTimer()
@@ -1041,8 +978,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
for (const id of this.producerResumesOwedOnReconnect) {
// Why: resuming a session the fresh daemon doesn't know is a harmless
// no-op; leaving a survivor paused would waste 5s of failsafe latency.
// Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency.
this.client.notify('resumePty', { sessionId: id })
}
this.producerResumesOwedOnReconnect.clear()
@@ -1071,14 +1007,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
) {
return
}
// Why: checkpointing is only needed after terminal data/resize/write marks
// a session dirty. A permanent interval woke the main process every 5s for
// idle daemon-backed terminals just to discover there was nothing to write.
// Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write.
this.checkpointTimer = setTimeout(() => {
this.checkpointTimer = null
// Why: if the previous pass is still in-flight (slow RPC or disk),
// retry later instead of overlapping checkpoint() writes to the same tmp
// file, which can lose a rename and disable future history writes.
// Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes.
if (this.checkpointInFlight) {
this.scheduleCheckpointTimer()
return
@@ -1102,10 +1034,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!this.historyManager || this.dirtySessionVersions.size === 0) {
return
}
// Why: getSnapshot serializes the daemon's terminal buffer. On large
// workspaces, checkpointing every live idle session every 5s burns CPU and
// disk for identical payloads; dirty versions keep retries precise without
// dropping writes that arrive during an in-flight checkpoint.
// Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces)
// while not dropping writes that arrive mid-checkpoint.
const versions = new Map(
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
)
@@ -1123,14 +1053,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.stopCheckpointTimerIfIdle()
}
// Why: the adapter runs in the Electron main process and does not have direct
// access to daemon Session objects. It calls checkpoint RPCs over the daemon
// socket per session. Returns a promise that resolves when all checkpoint
// writes complete (callers that don't need to wait can void it).
// Why final=true here: this runs on clean disconnect, where the full-depth
// snapshot (not the increment log) must be the restore source. It is not a
// teardown snapshot: the detached daemon and its PTYs keep running for warm
// reattach, so shell-ready scanner state must remain intact.
// Why final=true not teardown: clean disconnect needs the full-depth snapshot as the restore source, but the
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
private async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
for (const sessionId of completed) {
@@ -1162,8 +1086,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
teardown: opts?.teardown === true
})
.then((result) => {
// Why: deferred sessions stay dirty so the checkpoint timer keeps
// retrying until their full-snapshot cooldown expires.
// Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires.
if (result === 'done') {
completed.add(sessionId)
}
@@ -1171,9 +1094,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
}
}
// Why: snapshot serialization and checkpoint writes are CPU/disk heavy.
// Dirty-session filtering keeps idle terminals out; this cap prevents one
// tick from snapshotting every active dirty terminal at once.
// Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once.
const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () =>
checkpointNext()
)
@@ -1181,27 +1102,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
return completed
}
// Why cooldown starts only after a session's FIRST full snapshot: a session
// with no checkpoint on disk yet must be able to write one immediately or a
// cold restore would find nothing.
// Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately.
private isFullCheckpointCoolingDown(sessionId: string): boolean {
const last = this.lastFullCheckpointAt.get(sessionId)
if (last === undefined) {
return false
}
const elapsed = Date.now() - last
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not
// extend the deferral window.
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window.
return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS
}
// Why 'deferred' exists: a cap/overflow-triggered full snapshot inside the
// cooldown window is postponed, and the session must STAY dirty so the 5s
// timer keeps retrying until the cooldown expires. While deferred, no
// takePendingOutput/append runs for the session — appending past a dropped
// range would leave a hole in the log, whereas skipping keeps the on-disk
// state a consistent (merely stale) prefix that the eventual full snapshot
// re-anchors.
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
private async checkpointSession(
sessionId: string,
opts: { final: boolean; teardown: boolean }
@@ -1217,12 +1130,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) {
return 'deferred'
}
// Why take-with-snapshot instead of plain getSnapshot: the take clears
// the daemon's pending records in the same synchronous turn as the
// serialize. A plain snapshot would leave pre-snapshot records pending;
// a later warm reattach would append them to the fresh log and cold
// restore would replay them on top of a checkpoint that already
// contains them.
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown })
this.sessionsNeedingFullCheckpoint.delete(sessionId)
return 'done'
@@ -1234,8 +1143,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return 'done'
}
if (take.overflowed) {
// Why: overflow dropped records, so the log has a hole — only a full
// snapshot (which reflects everything ever written) can re-anchor it.
// Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
@@ -1255,8 +1163,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
take.records
)
if (appendResult === 'needs-checkpoint') {
// Why dropping take.records is lossless: they were applied to the live
// emulator before the take, so the snapshot below contains them.
// Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
@@ -1279,26 +1186,25 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.historyManager.checkpoint(sessionId, take.snapshot)
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0) {
// Why: take-with-snapshot usually returns no records because the
// snapshot subsumes them. Held parser-state bytes, such as an
// incomplete shell-ready marker prefix, are not representable in the
// snapshot and must remain as a post-checkpoint log tail.
// Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
}
}
// Why: when the daemon process dies, operations fail with ENOENT (socket
// gone), ECONNREFUSED, or "Connection lost" (socket closed mid-request).
// Rather than leaving all terminals permanently broken until app restart,
// this wrapper detects daemon-death errors, tears down the stale client
// state, forks a fresh daemon via respawnFn, reconnects, and retries the
// operation once. If respawn itself fails, the error propagates normally.
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.
private async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
if (!this.respawnFn || !isDaemonGoneError(err)) {
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
const missingRetiredEndpointToken =
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
if (
this.respawnAdoptionClosed ||
!this.respawnFn ||
(!isDaemonGoneError(err) && !missingRetiredEndpointToken)
) {
throw err
}
if (!this.respawnPromise) {
@@ -1307,7 +1213,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
}
await this.respawnPromise
return await fn()
try {
return await fn()
} finally {
// Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn).
this.releasePendingRespawnAdoptionLease()
}
}
}
@@ -1336,8 +1247,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
// Why: replacing the daemon kills its sessions without daemon-side exit
// fanout. Emit exits first so renderer panes do not write to dead PTYs.
// Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs.
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
@@ -1371,7 +1281,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
await this.respawnFn!()
const releaseAdoptionLease = await this.respawnFn!()
if (this.respawnAdoptionClosed) {
// Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns.
releaseAdoptionLease?.()
throw new Error('Daemon adapter closed during respawn')
}
this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null
}
private releasePendingRespawnAdoptionLease(): void {
const release = this.pendingRespawnAdoptionRelease
this.pendingRespawnAdoptionRelease = null
release?.()
}
private setupEventRouting(): void {
@@ -1392,9 +1314,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
listener({
id: event.sessionId,
data: event.payload.data,
...(event.payload.sequenceChars === undefined
...((event.payload.rawLength ?? event.payload.sequenceChars) === undefined
? {}
: { sequenceChars: event.payload.sequenceChars })
: { sequenceChars: event.payload.rawLength ?? event.payload.sequenceChars }),
...(event.payload.transformed ? { transformed: true } : {}),
...(event.payload.seq === undefined ? {} : { seq: event.payload.seq })
})
}
} else if (event.event === 'sessionBackgroundMarker') {
@@ -1424,21 +1348,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
} else if (event.event === 'exit') {
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
// Why: an exited session must not be owed a resume on reconnect — a
// reused sessionId would receive a stray resumePty. Same for the
// background set: a reused id must start un-thinned.
// Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state.
this.pausedProducerSessionIds.delete(event.sessionId)
this.producerResumesOwedOnReconnect.delete(event.sessionId)
this.backgroundedSessionIds.delete(event.sessionId)
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
this.coldRestoreCache.delete(event.sessionId)
}
// Why: an exited session can never be checkpointed again, so its pending
// full-checkpoint flag is dead state. Without this, a cold-restored
// session that exits before its first checkpoint leaks a permanent entry.
// Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak.
this.sessionsNeedingFullCheckpoint.delete(event.sessionId)
// Why: a reused sessionId (renderer respawns a persisted ptyId) must
// not inherit the dead session's snapshot cooldown.
// Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown.
this.lastFullCheckpointAt.delete(event.sessionId)
this.stopCheckpointTimerIfIdle()
if (this.historyManager) {
@@ -1455,18 +1374,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
})
}
async closeStartupQueryAuthority(id: string): Promise<number> {
if (!this.supportsStartupIngress) {
return 0
}
const result = await this.client.request<{ appliedSeq: number }>('closeStartupQueryAuthority', {
sessionId: id
})
return result.appliedSeq
}
}
// Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is
// unreachable (daemon died). Checking syscall avoids false positives from
// token-file ENOENT (readFileSync), which has no syscall or syscall='open'.
// "Connection lost" / "Not connected" mean the daemon died while we had an
// active or stale connection. "Hello response timed out" means we reconnected
// to a daemon whose socket accepts connections but whose event loop never
// answers the handshake (a wedged daemon, #8689) — respawning re-enters the
// grace-bounded launcher, which drains a transient wedge or replaces a
// permanent one instead of failing every terminal forever. All indicate the
// daemon is unusable and a respawn should be attempted.
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
function isDaemonGoneError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
@@ -1476,5 +1397,18 @@ function isDaemonGoneError(err: unknown): boolean {
return true
}
const msg = err.message
return msg === 'Connection lost' || msg === 'Not connected' || msg === 'Hello response timed out'
return (
msg === 'Connection lost' ||
msg === 'Not connected' ||
msg === 'Hello response timed out' ||
msg === 'Daemon temporarily unavailable; reconnect'
)
}
function isMissingTokenFileError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'open'
}
+11 -2
View File
@@ -64,8 +64,17 @@ export class DaemonPtyProvider {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
// Why: convert the absolute teardown deadline to a relative timeout only here,
// at the RPC leaf; undefined keeps the DaemonClient 30s default.
await this.client.request(
'kill',
{ sessionId: id, immediate: opts.immediate ?? false },
opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined
)
}
onData(callback: (payload: { id: string; data: string }) => void): () => void {
+21 -4
View File
@@ -17,6 +17,8 @@ export class DaemonPtyRouter implements IPtyProvider {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
@@ -99,7 +101,10 @@ export class DaemonPtyRouter implements IPtyProvider {
this.adapterFor(id).setPtyBackgrounded(id, background)
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
await this.adapterFor(id).shutdown(id, opts)
// Why: sleep passes keepHistory=true and re-spawns against the same
// sessionId on wake. If we delete the routing entry here, adapterFor()
@@ -143,6 +148,10 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.adapterFor(id).clearBuffer(id)
}
async closeStartupQueryAuthority(id: string): Promise<number> {
return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0
}
acknowledgeDataEvent(id: string, charCount: number): void {
this.adapterFor(id).acknowledgeDataEvent(id, charCount)
}
@@ -167,10 +176,12 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.current.revive(state)
}
async listProcesses(): Promise<PtyProcessInfo[]> {
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
// cannot provide a trustworthy process list.
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
const results = await Promise.all(
this.allAdapters().map((adapter) => adapter.listProcesses(opts))
)
return results.flat()
}
@@ -183,7 +194,13 @@ export class DaemonPtyRouter implements IPtyProvider {
}
onData(
callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
this.dataListeners.push(callback)
return () => {
@@ -0,0 +1,228 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session'
function fixtureSubprocess(): SubprocessHandle {
let onExit: ((code: number) => void) | null = null
return {
pid: process.pid,
getForegroundProcess: () => null,
write: () => {},
resize: () => {},
kill: () => queueMicrotask(() => onExit?.(0)),
forceKill: () => queueMicrotask(() => onExit?.(137)),
signal: () => {},
onData: () => {},
onExit: (callback) => {
onExit = callback
},
dispose: () => {}
}
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for daemon disconnect')
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
describe('daemon self-retirement respawn', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer | null
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-retirement-respawn-'))
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
server = null
})
afterEach(async () => {
await server?.shutdown().catch(() => {})
rmSync(dir, { recursive: true, force: true })
})
async function startServer(): Promise<DaemonServer> {
const next = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => fixtureSubprocess()
})
await next.start()
server = next
return next
}
it('coalesces respawn after an authenticated endpoint removes its token', async () => {
const original = await startServer()
const respawn = vi.fn(async () => {
await startServer()
})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.listProcesses()
const client = (
adapter as unknown as {
client: { hasObservedAuthenticatedDisconnect(): boolean }
}
).client
await original.shutdown()
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
await Promise.all([
adapter.spawn({ sessionId: 'first', cols: 80, rows: 24 }),
adapter.spawn({ sessionId: 'second', cols: 80, rows: 24 })
])
expect(respawn).toHaveBeenCalledTimes(1)
adapter.dispose()
})
it('releases the temporary respawn lease before clean retirement', async () => {
const original = await startServer()
let temporaryAdapter: DaemonPtyAdapter | null = null
const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
const respawn = vi.fn(async () => {
await startServer()
temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await temporaryAdapter.establishLifecycleLease()
return releaseTemporaryLease
})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.listProcesses()
const client = (
adapter as unknown as {
client: { hasObservedAuthenticatedDisconnect(): boolean }
}
).client
await original.shutdown()
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
await adapter.spawn({ sessionId: 'respawned', cols: 80, rows: 24 })
expect(releaseTemporaryLease).toHaveBeenCalledOnce()
await adapter.shutdown('respawned', { immediate: true })
await adapter.disconnectOnly()
await waitFor(() => !existsSync(tokenPath))
})
it('releases a temporary lease when a tombstone wins the respawn race', async () => {
const original = await startServer()
let temporaryAdapter: DaemonPtyAdapter | null = null
const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
let adapter!: DaemonPtyAdapter
const respawn = vi.fn(async () => {
await startServer()
temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await temporaryAdapter.establishLifecycleLease()
const tombstones = (adapter as unknown as { killedSessionTombstones: Map<string, number> })
.killedSessionTombstones
tombstones.set('closed-during-respawn', Date.now())
return releaseTemporaryLease
})
adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.listProcesses()
const client = (
adapter as unknown as {
client: { hasObservedAuthenticatedDisconnect(): boolean }
}
).client
await original.shutdown()
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
await expect(
adapter.spawn({ sessionId: 'closed-during-respawn', cols: 80, rows: 24 })
).rejects.toThrow('was explicitly killed')
expect(releaseTemporaryLease).toHaveBeenCalledOnce()
adapter.dispose()
})
it('releases a lease returned after disposal and does not reconnect', async () => {
const original = await startServer()
let temporaryAdapter: DaemonPtyAdapter | null = null
const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
let returnRespawnLease!: () => void
const respawn = vi.fn(async () => {
await startServer()
temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await temporaryAdapter.establishLifecycleLease()
await new Promise<void>((resolve) => {
returnRespawnLease = resolve
})
return releaseTemporaryLease
})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.listProcesses()
const client = (
adapter as unknown as {
client: { hasObservedAuthenticatedDisconnect(): boolean; isConnected(): boolean }
}
).client
await original.shutdown()
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
const spawn = adapter.spawn({ sessionId: 'disposed', cols: 80, rows: 24 })
await waitFor(() => returnRespawnLease !== undefined)
adapter.dispose()
returnRespawnLease()
await expect(spawn).rejects.toThrow('closed during respawn')
expect(releaseTemporaryLease).toHaveBeenCalledOnce()
expect(client.isConnected()).toBe(false)
})
it('does not start a respawn after disposal rejects an in-flight operation', async () => {
await startServer()
const respawn = vi.fn(async () => {})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.establishLifecycleLease()
let rejectCreate!: (error: Error) => void
const client = (
adapter as unknown as {
client: {
request: (method: string, params: unknown) => Promise<unknown>
}
}
).client
vi.spyOn(client, 'request').mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectCreate = reject
})
)
const spawn = adapter.spawn({ sessionId: 'disposed-in-flight', cols: 80, rows: 24 })
await waitFor(() => rejectCreate !== undefined)
adapter.dispose()
rejectCreate(new Error('Connection lost'))
await expect(spawn).rejects.toThrow('Connection lost')
expect(respawn).not.toHaveBeenCalled()
})
it('does not treat an initial missing token as respawn authority', async () => {
const respawn = vi.fn(async () => {})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject(
{
code: 'ENOENT'
}
)
expect(respawn).not.toHaveBeenCalled()
adapter.dispose()
})
})
+81 -14
View File
@@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
import type { SubprocessHandle } from './session'
import { getDaemonSocketPath } from './daemon-spawner'
import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
const confirmForegroundProcessMock = vi.fn(async () => 'droid')
@@ -58,6 +58,7 @@ type DaemonServerPrivate = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
@@ -67,6 +68,7 @@ describe('DaemonServer', () => {
let dir: string
let socketPath: string
let tokenPath: string
let pidPath: string
let server: DaemonServer
let client: DaemonClient
@@ -75,6 +77,7 @@ describe('DaemonServer', () => {
dir = createTestDir()
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'test.token')
pidPath = getDaemonPidPath(dir)
})
afterEach(async () => {
@@ -83,10 +86,11 @@ describe('DaemonServer', () => {
rmSync(dir, { recursive: true, force: true })
})
async function startServer(): Promise<void> {
async function startServer(launchNonce?: string): Promise<void> {
server = new DaemonServer({
socketPath,
tokenPath,
...(launchNonce ? { pidPath, launchNonce } : {}),
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
@@ -290,12 +294,12 @@ describe('DaemonServer', () => {
spawnSubprocess
})
await server.start()
const daemon = server as unknown as DaemonServerPrivate
const c = await connectClient()
const create = daemon.routeRequest('shutdown-client', {
id: 'shutdown-create',
type: 'createOrAttach',
payload: { sessionId: 'shutdown-pending', cols: 80, rows: 24 }
const create = c.request('createOrAttach', {
sessionId: 'shutdown-pending',
cols: 80,
rows: 24
})
const canceledCreate = expect(create).rejects.toThrow(
'Attach canceled for session shutdown-pending'
@@ -535,7 +539,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
@@ -591,7 +596,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
@@ -648,7 +654,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
id: 'req-1',
@@ -780,6 +787,68 @@ describe('DaemonServer', () => {
})
describe('shutdown', () => {
it('waits for the ordinary shutdown reply write before destroying resources', async () => {
await startServer()
const c = await connectClient()
const daemon = server as unknown as DaemonServerPrivate & {
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
chunk: string | Uint8Array,
...args: unknown[]
) => {
replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined
return originalWrite(chunk)
}) as unknown as Socket['write'])
const dispose = vi.spyOn(daemon.host, 'dispose')
await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({})
expect(dispose).not.toHaveBeenCalled()
expect(existsSync(tokenPath)).toBe(true)
replyFlushed?.()
await waitFor(() => !existsSync(tokenPath))
expect(dispose).toHaveBeenCalledOnce()
})
it('removes only its owned token and PID record', async () => {
const launchNonce = 'ordinary-shutdown'
writeFileSync(
pidPath,
serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce })
)
await startServer(launchNonce)
await server.shutdown()
expect(existsSync(tokenPath)).toBe(false)
expect(existsSync(pidPath)).toBe(false)
})
it('preserves token and PID artifacts replaced before ordinary cleanup', async () => {
await startServer('mine')
writeFileSync(tokenPath, 'replacement-token')
writeFileSync(
pidPath,
serializeDaemonPidFile({
pid: process.pid,
startedAtMs: null,
launchNonce: 'replacement'
})
)
await server.shutdown()
expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token')
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
pid: process.pid,
launchNonce: 'replacement'
})
})
it('stops accepting connections after shutdown', async () => {
await startServer()
await server.shutdown()
@@ -800,9 +869,7 @@ describe('DaemonServer', () => {
)
const c = await connectClient()
// The daemon may self-terminate before the reply flushes; callers treat
// that as success, so only the observable teardown below is asserted.
await c.request('shutdown', { killSessions: true }).catch(() => {})
await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
await waitFor(() => daemon.server === null)
await waitFor(() => !existsSync(socketPath))
+432 -129
View File
@@ -1,10 +1,8 @@
/* eslint-disable max-lines -- Why: this class owns the daemon socket protocol,
request routing, stream fanout, and session lifecycle in one place so
renderer/daemon request semantics stay auditable across platform branches. */
/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */
import { createServer, type Server, type Socket } from 'node:net'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import { writeFileSync, chmodSync, unlinkSync } from 'node:fs'
import { writeFileSync, chmodSync } from 'node:fs'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { TerminalHost } from './terminal-host'
@@ -23,7 +21,11 @@ import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority'
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
NOTIFY_PREFIX,
SessionNotFoundError,
@@ -35,6 +37,21 @@ import {
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
startedAtMs?: number
/** Direct-construction seam for protocol fixture tests; production never overrides it. */
protocolVersion?: number
onIdleShutdown?: () => void
/** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */
initialAdoptionTestConfig?: {
timeoutMs: number
clock: {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
}
ptySpawnHealthCheck?: () => Promise<void>
preparePtySpawn?: () => Promise<void>
log?: DaemonFileLog
@@ -53,21 +70,45 @@ type ConnectedClient = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
type PendingPtySpawnPreparation = {
canceled: boolean
}
type PendingShutdownReply = {
start: () => void
}
export class DaemonServer {
// Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first.
private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
private server: Server | null = null
private token: string
private host: TerminalHost
private socketPath: string
private tokenPath: string
private pidPath: string | null
private launchNonce: string | null
private startedAtMs: number | null
private protocolVersion: number
private onIdleShutdown: () => void
private ptySpawnHealthCheck: () => Promise<void>
private preparePtySpawn: () => Promise<void>
private log: DaemonFileLog
private transportSockets = new Set<Socket>()
private createOrAttachInFlight = 0
private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running'
private initialAdoptionTimer: unknown | null = null
private initialAdoptionDeadlineMs: number | null = null
private retirementRequested = false
private shutdownPromise: Promise<void> | null = null
private ordinaryShutdownServerClose: Promise<void> | null = null
private pendingShutdownReplies = new Map<string, PendingShutdownReply>()
private initialAdoptionTimeoutMs: number
private lifecycleClock: NonNullable<DaemonServerOptions['initialAdoptionTestConfig']>['clock']
private clients = new Map<string, ConnectedClient>()
private streamDataBatcher = new DaemonStreamDataBatcher(
@@ -86,10 +127,7 @@ export class DaemonServer {
}
}
)
// Fact scan authority for backgrounded sessions — facts ride the stream
// queue as control entries so they hold byte order with the data around
// them (a fact jumping the queue could arrive after the reveal snapshot
// that already reflects it).
// Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot).
private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => {
const clientId = this.streamClientIdBySessionId.get(sessionId)
if (clientId) {
@@ -106,16 +144,35 @@ export class DaemonServer {
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
private stopStreamBacklogProbe: () => void = () => {}
// Why: main-process PTY IPC has the same recent-input bypass, but daemon
// output reaches main only after this stream layer. Keeping the window here
// removes the daemon's fixed batch delay from keystroke echo/redraws while
// preserving batching for background and large output.
// Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay.
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
constructor(opts: DaemonServerOptions) {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.launchNonce =
opts.launchNonce ??
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null)
this.startedAtMs =
opts.startedAtMs ??
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
? Date.now() - process.uptime() * 1000
: null)
this.onIdleShutdown = opts.onIdleShutdown ?? (() => {})
this.initialAdoptionTimeoutMs =
opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS
this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? {
setTimeout: (callback, delayMs) => {
const timer = setTimeout(callback, delayMs)
timer.unref()
return timer
},
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
now: () => Date.now()
}
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
@@ -141,8 +198,7 @@ export class DaemonServer {
this.server.once('error', onListenError)
this.server.listen(this.socketPath, () => {
// Why: after bind, steady-state socket errors are handled per client;
// the startup promise listener would otherwise retain this closure.
// Why: drop the startup error listener after bind so it doesn't retain this closure.
this.server?.off('error', onListenError)
writeFileSync(this.tokenPath, this.token, { mode: 0o600 })
try {
@@ -150,50 +206,190 @@ export class DaemonServer {
} catch {
// Best-effort on platforms that support it
}
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
// Why: a parent crash before the first full client pair must not leave an empty daemon alive forever.
this.armInitialAdoptionTimeout()
}
resolve()
})
})
}
async shutdown(): Promise<void> {
if (this.shutdownPromise) {
return this.shutdownPromise
}
const serverClose = this.beginOrdinaryShutdownFence()
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
return this.shutdownPromise
}
private beginOrdinaryShutdownFence(): Promise<void> {
this.idleShutdownState = 'shutting-down'
this.cancelInitialAdoptionTimer()
this.ordinaryShutdownServerClose ??= this.beginServerClose()
return this.ordinaryShutdownServerClose
}
private async finishOrdinaryShutdown(serverClose: Promise<void>): Promise<void> {
this.unlinkOwnedEndpointArtifacts()
await this.disposeDaemonResources()
await serverClose
}
private unlinkOwnedEndpointArtifacts(): void {
// Why: ownership checks prevent removing a late replacement's token or PID record.
unlinkOwnedDaemonTokenFile(this.tokenPath, this.token)
if (this.pidPath && this.launchNonce) {
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
}
}
private async disposeDaemonResources(): Promise<void> {
this.stopStreamBacklogProbe()
this.transientFactRelay.dispose()
this.cancelAllPendingPtySpawnPreparations()
try {
await this.host.dispose()
} catch (err) {
// Why: an unreapable child must not block daemon exit — after exit it
// reparents to init, while a blocked daemon would orphan alongside it.
// Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
}
this.streamDataBatcher.clear()
this.pendingShutdownReplies.clear()
for (const [, client] of this.clients) {
client.controlSocket.destroy()
client.streamSocket?.destroy()
}
this.clients.clear()
for (const socket of this.transportSockets) {
socket.destroy()
}
this.transportSockets.clear()
}
private beginServerClose(): Promise<void> {
const server = this.server
this.server = null
if (!server) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
if (this.server) {
this.server.close(() => {
try {
unlinkSync(this.socketPath)
} catch {}
resolve()
})
this.server = null
} else {
// Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof.
server.close(() => {
// Node owns unlinking its Unix listener; an extra unlink here could delete a concurrent replacement.
resolve()
}
})
})
}
private isIdle(): boolean {
return (
this.transportSockets.size === 0 &&
this.clients.size === 0 &&
this.createOrAttachInFlight === 0 &&
this.host.listSessions().length === 0
)
}
private reevaluateIdleShutdown(): void {
if (this.idleShutdownState !== 'running') {
return
}
if (this.retirementRequested) {
this.cancelInitialAdoptionTimer()
if (this.isIdle()) {
this.beginIdleShutdown()
}
return
}
if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) {
this.cancelInitialAdoptionTimer()
return
}
if (this.initialAdoptionTimer !== null) {
return
}
const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now())
if (remainingMs === 0) {
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
return
}
this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => {
this.initialAdoptionTimer = null
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
}, remainingMs)
}
private armInitialAdoptionTimeout(): void {
this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs
this.reevaluateIdleShutdown()
}
private cancelInitialAdoptionTimer(): void {
if (this.initialAdoptionTimer === null) {
return
}
this.lifecycleClock.clearTimeout(this.initialAdoptionTimer)
this.initialAdoptionTimer = null
}
private beginIdleShutdown(): void {
this.initialAdoptionTimer = null
if (this.idleShutdownState !== 'running') {
return
}
this.idleShutdownState = 'idle-shutdown-pending'
if (!this.isIdle()) {
// Why: work admitted before the fence wins; clear pending state to keep it usable.
this.idleShutdownState = 'running'
this.reevaluateIdleShutdown()
return
}
this.idleShutdownState = 'shutting-down'
// beginServerClose() runs synchronously up to server.close() before any yield to a racing connection.
const serverClose = this.beginServerClose()
this.shutdownPromise = this.finishIdleShutdown(serverClose)
}
private async finishIdleShutdown(serverClose: Promise<void>): Promise<void> {
this.unlinkOwnedEndpointArtifacts()
await this.disposeDaemonResources()
await serverClose
this.onIdleShutdown()
}
private handleConnection(socket: Socket): void {
// Why: clients can send multibyte prompt/input text split across socket
// chunks; keep UTF-8 sequences intact before NDJSON parsing.
this.cancelInitialAdoptionTimer()
this.transportSockets.add(socket)
const removeTransport = (): void => {
this.transportSockets.delete(socket)
this.reevaluateIdleShutdown()
}
socket.once('close', removeTransport)
socket.on('error', () => socket.destroy())
if (this.idleShutdownState !== 'running') {
// Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth.
socket.end(
encodeNdjson({
type: 'hello',
ok: false,
error: 'Daemon temporarily unavailable; reconnect',
retryable: true
})
)
return
}
// Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleFirstMessage(socket, msg, parser),
@@ -203,7 +399,6 @@ export class DaemonServer {
)
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
socket.on('error', () => socket.destroy())
}
private handleFirstMessage(
@@ -219,7 +414,7 @@ export class DaemonServer {
return
}
if (hello.version !== PROTOCOL_VERSION) {
if (hello.version !== this.protocolVersion) {
this.log.log('client-hello-rejected', {
reason: 'protocol-mismatch',
clientVersion: hello.version
@@ -237,39 +432,56 @@ export class DaemonServer {
}
this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
socket.write(encodeNdjson({ type: 'hello', ok: true }))
socket.write(
encodeNdjson({
type: 'hello',
ok: true,
...(this.launchNonce && this.startedAtMs
? {
daemonIdentity: {
pid: process.pid,
startedAtMs: this.startedAtMs,
launchNonce: this.launchNonce
}
}
: {})
})
)
if (hello.role === 'control') {
const previous = this.clients.get(hello.clientId)
const client: ConnectedClient = {
clientId: hello.clientId,
controlSocket: socket,
streamSocket: null
streamSocket: null,
authenticatedPairEstablished: false
}
this.clients.set(hello.clientId, client)
this.setupControlSocket(socket, hello.clientId)
if (previous) {
// Why: a reconnect can reuse a clientId before the old sockets notice
// their close. Tear them down after installing the new owner so stale
// close events cannot delete the replacement client entry.
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
// Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement.
previous.streamSocket?.destroy()
previous.controlSocket.destroy()
}
} else if (hello.role === 'stream') {
const client = this.clients.get(hello.clientId)
if (!client) {
// Why: stream sockets are only meaningful beside a control socket; an
// orphan stream would otherwise stay open with no tracked owner.
// Why: a stream socket is meaningless without its control socket; drop the orphan.
socket.destroy()
return
}
this.setupStreamSocket(socket, client)
client.authenticatedPairEstablished = true
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
}
}
private setupControlSocket(socket: Socket, clientId: string): void {
// Why: terminal writes and startup commands can contain emoji/Unicode.
// Decoding per Buffer would corrupt split multibyte sequences.
// Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => this.handleRequest(socket, clientId, msg as DaemonRequest),
@@ -285,12 +497,27 @@ export class DaemonServer {
if (client?.controlSocket !== socket) {
return
}
const wasFullyAuthenticated = client.authenticatedPairEstablished
this.streamDataBatcher.clear(clientId)
client.streamSocket?.destroy()
this.clients.delete(clientId)
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
this.reevaluateIdleShutdown()
})
}
private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
if (
!wasFullyAuthenticated ||
[...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) ||
this.idleShutdownState !== 'running'
) {
return
}
// Why: once the last full client is gone, incomplete transports may block retirement but never erase it.
this.retirementRequested = true
}
private setupStreamSocket(socket: Socket, client: ConnectedClient): void {
const previous = client.streamSocket
socket.removeAllListeners('data')
@@ -314,8 +541,7 @@ export class DaemonServer {
socket.on('error', cleanup)
if (previous && previous !== socket) {
// Why: replacing a stream socket must not leave the old receive-only
// channel alive and untracked.
// Why: replacing a stream socket must not leave the old channel alive and untracked.
previous.destroy()
}
}
@@ -330,7 +556,12 @@ export class DaemonServer {
try {
const result = await this.routeRequest(clientId, request)
if (!isNotify) {
socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }))
const pendingShutdown = this.pendingShutdownReplies.get(
this.shutdownReplyKey(clientId, request.id)
)
socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => {
pendingShutdown?.start()
})
}
} catch (err) {
if (!isNotify) {
@@ -345,14 +576,47 @@ export class DaemonServer {
}
}
private shutdownReplyKey(clientId: string, requestId: string): string {
return `${clientId}\u0000${requestId}`
}
private deferShutdownUntilReply(
clientId: string,
requestId: string,
socket: Socket,
finish: () => Promise<void>
): void {
const key = this.shutdownReplyKey(clientId, requestId)
let started = false
let timer: ReturnType<typeof setTimeout>
const start = (): void => {
if (started) {
return
}
started = true
clearTimeout(timer)
socket.off('close', start)
socket.off('error', start)
this.pendingShutdownReplies.delete(key)
if (!this.shutdownPromise) {
this.shutdownPromise = finish()
}
}
// Why: a non-reading peer must not pin a fenced daemon by holding its ack behind permanent socket backpressure.
timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
timer.unref()
socket.once('close', start)
socket.once('error', start)
this.pendingShutdownReplies.set(key, { start })
}
private async preparePtySpawnUnlessCanceled(sessionId: string): Promise<void> {
const preparation: PendingPtySpawnPreparation = { canceled: false }
const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set()
pending.add(preparation)
this.pendingPtySpawnPreparations.set(sessionId, pending)
try {
// Why: registration precedes the async capability probe so a concurrent
// close can cancel this exact creation before a subprocess exists.
// Why: register before the async probe so a concurrent close can cancel this creation before a subprocess exists.
await this.preparePtySpawn()
if (preparation.canceled) {
throw new TerminalAttachCanceledError(sessionId)
@@ -387,68 +651,87 @@ export class DaemonServer {
switch (request.type) {
case 'createOrAttach': {
if (this.idleShutdownState !== 'running') {
throw new Error('Daemon temporarily unavailable; reconnect')
}
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
// Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request.
throw new Error('Daemon client connection is incomplete; reconnect')
}
this.createOrAttachInFlight++
const p = request.payload
await this.preparePtySpawnUnlessCanceled(p.sessionId)
const result = await this.host.createOrAttach({
sessionId: p.sessionId,
cols: p.cols,
rows: p.rows,
cwd: p.cwd,
env: p.env,
envToDelete: p.envToDelete,
command: p.command,
startupCommandDelivery: p.startupCommandDelivery,
// Why: daemon RPC payloads are untrusted JSON. Persist only the
// allowlisted enum used for byte routing, never arbitrary identity.
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
shellOverride: p.shellOverride,
terminalWindowsWslDistro: p.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
shellReadySupported: p.shellReadySupported,
historySeed: p.historySeed,
...(p.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
streamClient: {
onData: (data) => {
// Scan BEFORE enqueue: the batcher may keep-tail drop this
// chunk, but its facts must be captured regardless.
this.transientFactRelay.onSessionData(p.sessionId, data)
const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
const isInteractiveOutput =
data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS
let result: Awaited<ReturnType<TerminalHost['createOrAttach']>>
try {
await this.preparePtySpawnUnlessCanceled(p.sessionId)
result = await this.host.createOrAttach({
sessionId: p.sessionId,
cols: p.cols,
rows: p.rows,
cwd: p.cwd,
env: p.env,
envToDelete: p.envToDelete,
command: p.command,
startupCommandDelivery: p.startupCommandDelivery,
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
shellOverride: p.shellOverride,
terminalWindowsWslDistro: p.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
shellReadySupported: p.shellReadySupported,
historySeed: p.historySeed,
startupIngress: parsePtyStartupIngressIntent(p.startupIngress, {
allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({
connectionId: null,
cwd: p.cwd,
shellOverride: p.shellOverride
})
},
onExit: (code) => {
// Why: exit tears down renderer handlers, so it must ride the
// ordered queue behind final output even when the shallow socket
// gate holds that output for a later drain pass.
this.log.log('session-exited', { sessionId: p.sessionId, code })
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
type: 'event',
event: 'exit',
sessionId: p.sessionId,
payload: { code }
})
this.streamDataBatcher.flush(clientId)
recordDaemonStreamBacklogEvent('sessionExit', {
sessionIdSuffix: p.sessionId.slice(-10)
})
this.transientFactRelay.onSessionExit(p.sessionId)
this.streamClientIdBySessionId.delete(p.sessionId)
this.lastInputAtBySessionId.delete(p.sessionId)
}),
...(p.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
streamClient: {
onData: (data, rawLength = data.length, transformed = false, seq) => {
// Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless.
this.transientFactRelay.onSessionData(p.sessionId, data)
const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
const isInteractiveOutput =
data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq
})
},
onExit: (code) => {
// Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output.
this.log.log('session-exited', { sessionId: p.sessionId, code })
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
type: 'event',
event: 'exit',
sessionId: p.sessionId,
payload: { code }
})
this.streamDataBatcher.flush(clientId)
recordDaemonStreamBacklogEvent('sessionExit', {
sessionIdSuffix: p.sessionId.slice(-10)
})
this.transientFactRelay.onSessionExit(p.sessionId)
this.streamClientIdBySessionId.delete(p.sessionId)
this.lastInputAtBySessionId.delete(p.sessionId)
this.reevaluateIdleShutdown()
}
}
}
})
})
} finally {
this.createOrAttachInFlight--
this.reevaluateIdleShutdown()
}
this.streamClientIdBySessionId.set(p.sessionId, clientId)
// Why an attach-time marker: the adapter resyncs the background set on
// a fresh connection, which can precede this attach — main's scan
// suppression must still start at the head of the new stream.
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
if (this.transientFactRelay.isBackgrounded(p.sessionId)) {
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
type: 'event',
@@ -519,9 +802,7 @@ export class DaemonServer {
return {}
}
if (background) {
// Prime the fresh relay tracker with the emulator's dangling
// incomplete escape so a sequence split across the handoff parses
// exactly as if the relay had seen the whole stream.
// Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses.
this.transientFactRelay.seedSessionScanState(
sessionId,
this.host.getPartialEscapeTailAnsi(sessionId)
@@ -532,11 +813,7 @@ export class DaemonServer {
// Not attached yet — the attach-time marker covers the handoff.
return {}
}
// Reveal deliberately does NOT discard or force-flush the queued
// tail: main's model (hidden-output recovery buffer, tail previews)
// needs those bytes — a finished program's last output lives there —
// and the normal flush/drain loop delivers them within milliseconds
// (bounded ≤ the keep-tail drop cap), in order, ahead of the marker.
// Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker.
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
type: 'event',
@@ -562,8 +839,7 @@ export class DaemonServer {
try {
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
} catch (error) {
// Why: a kill that wins before session registration has already
// canceled the pending spawn and therefore completed its intent.
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
throw error
}
@@ -576,8 +852,7 @@ export class DaemonServer {
return {}
case 'detach':
// Note: detach token handling is simplified here — full implementation
// would track tokens per client
// Note: detach token handling simplified — full impl would track tokens per client
this.log.log('session-detached', { sessionId: request.payload.sessionId })
return {}
@@ -599,6 +874,34 @@ export class DaemonServer {
case 'listSessions':
return { sessions: this.host.listSessions() }
case 'shutdownIfIdle': {
const authenticatedClient = this.clients.get(clientId)
const retiring =
authenticatedClient !== undefined &&
authenticatedClient.streamSocket !== null &&
this.clients.size === 1 &&
this.createOrAttachInFlight === 0 &&
this.host.listSessions().length === 0 &&
[...this.transportSockets].every(
(transport) =>
transport === authenticatedClient.controlSocket ||
transport === authenticatedClient.streamSocket
)
if (!retiring) {
return { retiring: false }
}
this.idleShutdownState = 'shutting-down'
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
// Why: close before acknowledging retirement so no new terminal races between the empty proof and disposal.
const serverClose = this.beginServerClose()
this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () =>
this.finishIdleShutdown(serverClose)
)
return { retiring: true }
}
case 'getSnapshot': {
const snapshotStart = performance.now()
const requestedScrollbackRows = request.payload.scrollbackRows
@@ -609,9 +912,7 @@ export class DaemonServer {
const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows })
const snapshotMs = performance.now() - snapshotStart
if (snapshotMs >= 25) {
// Serialize stalls block the daemon's single thread — every pty's
// echo included. Surfaced here so multi-second typing stalls can be
// attributed to checkpoint storms (issue #5096 family) in the field.
// Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family).
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
sessionIdSuffix: request.payload.sessionId.slice(-10),
snapshotMs: Math.round(snapshotMs)
@@ -624,10 +925,7 @@ export class DaemonServer {
return { size: this.host.getAppliedSize(request.payload.sessionId) }
case 'takePendingOutput':
// Why no await before this call: with includeSnapshot, drain and
// serialize must share one synchronous turn — an intervening await
// would let PTY data land in between, and cold restore would replay
// those bytes on top of a snapshot that already contains them.
// Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes.
return this.host.takePendingOutput(
request.payload.sessionId,
request.payload.includeSnapshot === true,
@@ -644,25 +942,32 @@ export class DaemonServer {
await this.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
case 'shutdown': {
this.log.log('shutdown', {
reason: 'rpc',
killSessions: request.payload.killSessions === true
})
const serverClose = this.beginOrdinaryShutdownFence()
if (request.payload.killSessions) {
try {
await this.host.dispose()
} catch (err) {
// Why: the shutdown RPC contract is that the daemon always
// self-terminates; dispose keeps failed owners retryable, and the
// follow-up shutdown() below retries them once more before exit.
// Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
}
}
process.nextTick(() => this.shutdown())
const controlSocket = this.clients.get(clientId)?.controlSocket
if (controlSocket) {
this.deferShutdownUntilReply(clientId, request.id, controlSocket, () =>
this.finishOrdinaryShutdown(serverClose)
)
} else if (!this.shutdownPromise) {
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
}
return {}
}
}
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
@@ -675,9 +980,7 @@ export class DaemonServer {
if (!client?.streamSocket) {
return
}
// Why: write/resize are notification-heavy and intentionally do not wait
// for replies. If their target session is gone, this synthetic exit is the
// only signal the renderer gets to clear stale terminal pane bindings.
// Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings.
this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
type: 'event',
event: 'exit',

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