mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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&label=%E2%98%85&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:_
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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,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&label=%E2%98%85&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">
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logo OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logo Pi" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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é & 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 & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
|
||||
- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
|
||||
- **Soutenez-nous :** [Mettez une star](https://github.com/stablyai/orca) sur ce repo pour suivre nos ships quotidiens.
|
||||
|
||||
---
|
||||
|
||||
## Développement
|
||||
|
||||
Envie de contribuer ou de lancer le projet en local ? Consultez notre guide [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
|
||||
|
||||
<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,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&label=%E2%98%85&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,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&label=%E2%98%85&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,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&label=%E2%98%85&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,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&label=%E2%98%85&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.
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)}…`
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+758
-220
File diff suppressed because it is too large
Load Diff
+556
-433
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user