Merge remote-tracking branch 'origin/main' into brennanb2025/fix-hung-spawn-pane-pin

This commit is contained in:
Merge Sim
2026-09-08 10:29:52 -07:00
708 changed files with 26864 additions and 7185 deletions
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
image-digest:
description: "Immutable relay image digest (sha256: plus 64 lowercase hex characters)"
description: 'Immutable relay image digest (sha256: plus 64 lowercase hex characters)'
required: true
type: string
regional-placement-mode:
+2
View File
@@ -227,6 +227,7 @@ jobs:
mapfile -t TEST_FILES < <(jq -r '.[] | select(
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
. != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
@@ -272,6 +273,7 @@ jobs:
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
+2 -2
View File
@@ -14,8 +14,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"hono": "^4.12.27",
"@hono/node-server": "^1.19.17",
"hono": "^4.13.7",
"zod": "^3.25.76"
},
"devDependencies": {
+2 -2
View File
@@ -16,8 +16,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"hono": "^4.12.27",
"@hono/node-server": "^1.19.17",
"hono": "^4.13.7",
"zod": "^3.25.76"
},
"devDependencies": {
+3 -3
View File
@@ -15,13 +15,13 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@hono/node-server": "^1.19.17",
"@orca-cloud/relay-contract": "workspace:*",
"hono": "^4.12.27",
"hono": "^4.13.7",
"jose": "^6.1.3",
"pg": "^8.22.0",
"tweetnacl": "^1.0.3",
"ws": "^8.18.3",
"ws": "^8.21.3",
"zod": "^3.25.76"
},
"devDependencies": {
+32 -32
View File
@@ -24,14 +24,14 @@ importers:
apps/relay:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
'@orca-cloud/relay-contract':
specifier: workspace:*
version: link:../../packages/relay-contract
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
jose:
specifier: ^6.1.3
version: 6.2.3
@@ -42,8 +42,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
ws:
specifier: ^8.18.3
version: 8.21.0
specifier: ^8.21.3
version: 8.21.3
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -70,11 +70,11 @@ importers:
apps/relay-fence-broker:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -95,11 +95,11 @@ importers:
apps/relay-ops:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -300,8 +300,8 @@ packages:
cpu: [x64]
os: [win32]
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
'@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -507,8 +507,8 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
hono@4.13.7:
resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==}
engines: {node: '>=16.9.0'}
jose@6.2.3:
@@ -587,8 +587,8 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.13:
resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -640,8 +640,8 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
@@ -805,8 +805,8 @@ packages:
engines: {node: '>=8'}
hasBin: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -920,9 +920,9 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@hono/node-server@1.19.14(hono@4.12.27)':
'@hono/node-server@1.19.17(hono@4.13.7)':
dependencies:
hono: 4.12.27
hono: 4.13.7
'@jridgewell/sourcemap-codec@1.5.5': {}
@@ -1109,7 +1109,7 @@ snapshots:
fsevents@2.3.3:
optional: true
hono@4.12.27: {}
hono@4.13.7: {}
jose@6.2.3: {}
@@ -1166,7 +1166,7 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.13: {}
nanoid@3.3.18: {}
obug@2.1.3: {}
@@ -1211,9 +1211,9 @@ snapshots:
picomatch@4.0.4: {}
postcss@8.5.15:
postcss@8.5.28:
dependencies:
nanoid: 3.3.13
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -1288,7 +1288,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.28
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -1329,7 +1329,7 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.0: {}
ws@8.21.3: {}
xtend@4.0.2: {}
-5
View File
@@ -9,8 +9,3 @@ inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/relay/pty-handler.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/index.tsx
mobile-config scripts/mock-server.ts
mobile-config src/transport/rpc-client.ts
File diff suppressed because one or more lines are too long
@@ -1104,31 +1104,124 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
}
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..c6dcf18b762e3c56fe22e9c2d49b8e550d96f915 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
@@ -22,7 +22,8 @@ export class SortedList<T> {
private readonly _flushInsertedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingInserted = false;
- private readonly _deletedIndices: number[] = [];
+ private readonly _deletedIndices = new Set<number>();
+ private readonly _indicesByValue = new Map<T, number | number[]>();
private readonly _flushDeletedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingDeleted = false;
@@ -36,10 +37,11 @@ export class SortedList<T> {
public clear(): void {
this._array.length = 0;
+ this._indicesByValue.clear();
this._insertedValues.length = 0;
this._flushInsertedTask.clear();
this._isFlushingInserted = false;
- this._deletedIndices.length = 0;
+ this._deletedIndices.clear();
this._flushDeletedTask.clear();
this._isFlushingDeleted = false;
}
@@ -69,6 +71,7 @@ export class SortedList<T> {
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
this._array = newArray;
+ this._rebuildIdentityIndex();
this._insertedValues.length = 0;
}
@@ -78,54 +81,60 @@ export class SortedList<T> {
}
}
+ private _rebuildIdentityIndex(): void {
+ this._indicesByValue.clear();
+ // Reverse indices let duplicate identities remove their first occurrence in O(1).
+ for (let index = this._array.length - 1; index >= 0; index--) {
+ const value = this._array[index];
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
+ this._indicesByValue.set(value, index);
+ } else if (typeof indices === 'number') {
+ this._indicesByValue.set(value, [indices, index]);
+ } else {
+ indices.push(index);
+ }
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
public delete(value: T): boolean {
this._flushCleanupInserted();
- if (this._array.length === 0) {
+ // Marker disposal mutates the sort key before removal; identity stays stable.
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
return false;
}
- const key = this._getKey(value);
- if (key === undefined) {
+ const index = typeof indices === 'number' ? indices : indices.pop();
+ if (index === undefined) {
return false;
}
- i = this._search(key);
- if (i === -1) {
- return false;
+ if (typeof indices === 'number' || indices.length === 0) {
+ this._indicesByValue.delete(value);
}
- if (this._getKey(this._array[i]) !== key) {
- return false;
+ if (this._deletedIndices.size === 0) {
+ this._flushDeletedTask.enqueue(() => this._flushDeleted());
}
- do {
- if (this._array[i] === value) {
- if (this._deletedIndices.length === 0) {
- this._flushDeletedTask.enqueue(() => this._flushDeleted());
- }
- this._deletedIndices.push(i);
- return true;
- }
- } while (++i < this._array.length && this._getKey(this._array[i]) === key);
- return false;
+ this._deletedIndices.add(index);
+ return true;
}
private _flushDeleted(): void {
this._isFlushingDeleted = true;
- const sortedDeletedIndices = this._deletedIndices.sort((a, b) => a - b);
- let sortedDeletedIndicesIndex = 0;
- const newArray = new Array(this._array.length - sortedDeletedIndices.length);
+ const newArray = new Array(this._array.length - this._deletedIndices.size);
let newArrayIndex = 0;
for (let i = 0; i < this._array.length; i++) {
- if (sortedDeletedIndices[sortedDeletedIndicesIndex] === i) {
- sortedDeletedIndicesIndex++;
- } else {
+ if (!this._deletedIndices.has(i)) {
newArray[newArrayIndex++] = this._array[i];
}
}
this._array = newArray;
- this._deletedIndices.length = 0;
+ this._rebuildIdentityIndex();
+ this._deletedIndices.clear();
this._isFlushingDeleted = false;
}
private _flushCleanupDeleted(): void {
- if (!this._isFlushingDeleted && this._deletedIndices.length > 0) {
+ if (!this._isFlushingDeleted && this._deletedIndices.size > 0) {
this._flushDeletedTask.flush();
}
}
+221 -56
View File
@@ -10,6 +10,79 @@
}
},
"gates": [
{
"id": "agent-session.history-forward-read-budget",
"title": "Journal catch-up reads only the next page and one lookahead row",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session-runtime",
"layer": "runtime-unit",
"surfaces": ["structured agent history", "structured agent subscriptions"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh", "remote-runtime"],
"coverageNotes": "The real SQLite journal and production subscriber delivery are exercised with a folder workspace and remote host identity. The SQL and pagination code is shared across execution hosts; live SSH transport and Linux/Windows runtime execution are not exercised. PTY, daemon, WSL execution, and mobile rendering are unaffected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts"
],
"invariant": "Forward catch-up preserves every item, revision, tombstone, sequence cursor, page byte bound, and reset behavior while reading at most the requested row count plus one from SQLite for each page.",
"oracle": "Reconnect a real subscriber to a 2,000-row journal and receive all 2,000 item identities in order through the live cursor; count the actual SQL rows returned and parsed as 2,009 instead of 11,000. Assert exact final-page hasNewer, unlimited reader compatibility, gap detection at the next page, and parse-stop behavior at the lookahead row. Existing history tests cover revisions, tombstones, byte-bound shrinking, epochs, and schema resets.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-journal"
],
"testFiles": [
"src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts",
"src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts",
"src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts"
],
"assertionRefs": [
{
"file": "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts",
"assertions": [
"reconnects through every page with one lookahead row per page",
"keeps an exact final page final and preserves unlimited journal readers",
"reports a sequence gap when the next page reaches it",
"preserves parse-stop behavior at lookahead: %s"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-07",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-journal",
"result": "passed",
"durationSeconds": 9.94,
"summary": "214 tests passed across 19 files, including actual SQLite row and JSON parse counts through production subscriber catch-up."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "Real SQLite journal unit and production subscriber tests; no launched app."
},
"flakeHistory": {
"status": "not-started",
"evidence": "Initial deterministic local validation; CI soak has not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Before the change, SQL returned 2,000, 1,800, 1,600 through 200 rows across ten pages, failing the count assertion. The bounded query returns nine pages of 201 rows and a final 200, with exactly 2,009 row parses and identical item delivery."
},
"performanceBudget": {
"required": true,
"evidence": "Catch-up materialization and JSON parsing are linear in unseen journal rows plus page lookaheads. A cached parameterized LIMIT adds no polling, cache invalidation, output loss, protocol change, or provider calls."
},
"knownGaps": [
"Linux and Windows execution and live SSH transport have not been exercised.",
"The existing full reduced-state snapshot and batch projection cost are outside this SQL read budget."
],
"promotionCriteria": [
"Complete CI soak requirements while preserving the deterministic row budget and pagination oracles."
],
"demotionRule": "Keep experimental until CI soak; investigate fidelity or count failures without relaxing the row budget."
},
{
"id": "terminal-performance.padded-fullscreen-redraw",
"title": "Fullscreen redraw padding does not stall terminal delivery",
@@ -268,6 +341,106 @@
],
"demotionRule": "Keep experimental or demote if adoption duplicates covered output, drops newer or unproven output, changes terminal ownership, or flakes without explanation."
},
{
"id": "terminal-session.io-failure-cleanup",
"title": "Native PTY I/O failures preserve termination ownership",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "provider-contract",
"surfaces": ["daemon PTY teardown"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local-daemon", "ssh-daemon", "paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "Real TerminalHost, Session, and subprocess wrapper with injected native I/O failures and mocked OS signals. Local non-daemon and SSH-relay implementations are unaffected; daemon consumers on SSH, WSL, paired runtimes, and mobile retain host-owned semantics. Live Linux/Windows/WSL and remote runs remain gaps. No git or folder-workspace assumptions. The fault-injection suite also runs with simulated darwin/linux/win32 platform branches; these do not constitute native OS coverage. Native macOS coverage now proves shell exit and PTY master-fd closure, input/output round trips, and teardown of a paused producer for both graceful and immediate cleanup. Windows single-close/job escalation and pre-listener output/status are fault-injected contracts.",
"motivatingLinks": ["docs/terminal-daemon-session-leak-investigation.md"],
"invariant": "I/O errors must not establish physical exit or disable termination of an owned PTY. Session and native handle disposal require the exit event.",
"oracle": "Inject write and resize failures, require graceful and forced signals to reach the native owner, keep producer resume available, suppress repeated failed I/O, deliver output and exit, and suppress signals after exit. Across 32 create/close cycles per failure, retain each session before exit and release its native handle and emulator exactly once afterwards. Mark physical exit before notifying listeners; reentrant kill/forceKill/signal from those listeners must never signal the retired PID. A native POSIX test performs input/output and resize, pauses the producer, injects each I/O failure, then gracefully or immediately closes 16 real shells; require ESRCH for each child PID and EBADF for each PTY master fd.",
"commands": [
"pnpm test src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts",
"pnpm test src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts src/main/daemon/pty-subprocess-handle-lifecycle.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/terminal-host-teardown-recreate.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/session.test.ts",
"pnpm test src/main/daemon/pty-subprocess-io-failure-native.test.ts"
],
"testFiles": [
"src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts",
"src/main/daemon/pty-subprocess-handle-lifecycle.test.ts",
"src/main/daemon/terminal-host-session-reaping-leak.test.ts",
"src/main/daemon/terminal-host-teardown-recreate.test.ts",
"src/main/daemon/terminal-session-teardown.test.ts",
"src/main/daemon/session.test.ts",
"src/main/daemon/pty-subprocess-io-failure-native.test.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts",
"assertions": [
"keeps graceful and forced termination available until physical exit",
"reaps every session and native handle across 32 failed-I/O create/close cycles",
"suppresses repeated native I/O failures while still delivering output and exit",
"blocks reentrant termination from an exit listener after I/O failure"
]
},
{
"file": "src/main/daemon/pty-subprocess-io-failure-native.test.ts",
"assertions": ["reaps real shells and master fds after %s failure (immediate=%s)"]
}
],
"evidenceRuns": [
{
"date": "2026-09-07",
"runner": "local",
"platform": "macos",
"command": "pnpm test src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts src/main/daemon/pty-subprocess-handle-lifecycle.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/daemon/terminal-host-teardown-recreate.test.ts src/main/daemon/terminal-session-teardown.test.ts src/main/daemon/session.test.ts",
"result": "passed",
"durationSeconds": 0.617,
"summary": "136 tests passed across six files; failed-I/O cycle tests cover 64 closures."
},
{
"date": "2026-09-07",
"runner": "local",
"platform": "macos",
"command": "pnpm test src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts",
"result": "passed",
"durationSeconds": 3.71,
"summary": "32 tests passed with 4 Windows-only cases skipped; simulated macOS/Linux/Windows branches include 192 failed-I/O create/close cycles and exit-listener reentrancy."
},
{
"date": "2026-09-07",
"runner": "local",
"platform": "macos",
"command": "pnpm test src/main/daemon/pty-subprocess-io-failure-native.test.ts",
"result": "passed",
"durationSeconds": 2.52,
"summary": "Four native cases pass across 16 real shells, including input/output, pause before teardown, confirmed PID absence, and closed PTY master fds."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "focused daemon teardown contract tests"
},
"flakeHistory": {
"status": "not-started",
"evidence": "Initial deterministic local run; no soak history."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "All four original regression cases failed before the fix because native kill was never called; the unchanged cases passed after separating I/O failure from exit. Two additional output/flow-control cases also pass. Review added two failing exit-listener reentrancy cases; publishing physical exit before callbacks made them pass."
},
"performanceBudget": {
"required": true,
"evidence": "One boolean per PTY; no new timers, scans, retries, or subprocesses. Existing failed-I/O suppression remains. 192 closures under three simulated platform branches return session inventory to zero and dispose each emulator/native handle once."
},
"promotionCriteria": [
"Collect remaining native cross-platform evidence plus the standard soak history."
],
"knownGaps": [
"Fault injection proves a leak mechanism, not causality for the historical 427-session incident.",
"Real Linux/Windows/WSL, remote, startup-close, login-wrapper descendants, and multi-day load evidence remain outstanding. Native tests inject synchronous I/O errors; they do not model every asynchronous node-pty pipe failure.",
"No output throughput change or interactive latency benchmark is included."
],
"demotionRule": "Keep experimental; investigate any lost cleanup signal, premature exit, or unexplained flake."
},
{
"id": "cmd-j-tabs.host-qualified-candidate-ownership",
"title": "Cmd-J tab candidates retain execution-host ownership",
@@ -5825,7 +5998,7 @@
"invariant": "After a TUI exits or is killed, reveal, reattach, snapshot replay, or renderer remount must not deliver terminal-owned mouse or alternate-screen protocol bytes to the surviving shell. Recovery is an ordered output barrier in the daemon session data path: an OSC 133;D completing while the alternate screen is still active pauses the stream at that exact byte boundary, a fresh execution-host process inspection proves shell ownership, and on proof a mode reset is injected as in-stream output so every consumer converges by parsing the same bytes and the queued post-boundary shell output (the prompt) lands on the normal buffer. Snapshots are pure reads. Any failure — refuted proof, timeout, queue overflow, session death, disposal — flushes the queue unmodified, preserving incumbent behavior; later command or mode bytes revoke proof. Clean alternate-screen exits prove ownership asynchronously without pausing.",
"oracle": "Run one fixed child-TUI journey for normal exit and cleanup-free SIGKILL. Assert renderer and host normal-buffer/non-mouse state, host snapshot terminalOwner metadata, exact PTY writes with no post-exit mouse report, unrelated-pane survival, post-boundary prompt output preserved (normal exit), ordered proof invalidation, bounded settlement and bail-out flush, one inspection per unclean episode with zero scans for ordinary output, split-escape safety at every chunk boundary, and old/new client-host fallback parity.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/terminal-shell-lifecycle-scanner.test.ts src/main/daemon/terminal-shell-recovery-barrier.test.ts src/main/daemon/session-shell-recovery.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host-concurrent-create.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-restore-scrollback-depth.test.ts src/main/daemon/terminal-checkpoint-serializer.test.ts src/main/providers/agent-foreground-process.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts src/shared/terminal-partial-escape-tail.test.ts src/shared/terminal-partial-escape-tail.fuzz.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@@ -5847,6 +6020,8 @@
"src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts",
"src/shared/terminal-partial-escape-tail.test.ts",
"src/shared/terminal-partial-escape-tail.fuzz.test.ts",
"tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts",
"tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts"
],
@@ -5868,6 +6043,13 @@
"a snapshot taken during a split escape keeps the pending tail intact and stale proof is revoked by the completing bytes"
]
},
{
"file": "src/shared/terminal-partial-escape-tail.fuzz.test.ts",
"assertions": [
"the pending tail this gate threads over the wire folds identically at every code-unit split of the combined stream, including boundaries landing inside oscEsc/stringEsc",
"the split sweep runs over an alphabet carrying CAN, SUB, doubled ESC inside OSC/DCS/SOS/PM/APC, BEL, C1 ST, NUL, DEL, intermediates, CJK, astral, and lone surrogates"
]
},
{
"file": "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts",
"assertions": [
@@ -13325,9 +13507,7 @@
},
{
"file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
"assertions": [
"a refused pointer write drains a delivery parked behind its watermark"
]
"assertions": ["a refused pointer write drains a delivery parked behind its watermark"]
},
{
"file": "src/main/providers/settled-pty-writer-census.test.ts",
@@ -13478,6 +13658,7 @@
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. Federated terminal settlement must remain replay-eligible until the worker durably acknowledges it, and identical same-outcome retries must converge idempotently. Independently updated clients and worker servers must preserve the negotiated protocol: current peers use Run-home lifecycle settlement, while protocol v1/v2 peers retain their legacy completion path without receiving newer-only fields. A federated worker may accept only the authority defined by its negotiated protocol. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts",
@@ -13492,6 +13673,7 @@
"pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
"src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
"src/main/runtime/orchestration/formatter.test.ts",
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
@@ -13515,6 +13697,13 @@
"tests/e2e/orchestration-worker-settlement-release-cli.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts",
"assertions": [
"replays the coordinator instruction and takes its ack after the app restarts",
"files loopback mail once under the local Dispatch Run without replacing its owner"
]
},
{
"file": "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
"assertions": [
@@ -18303,7 +18492,7 @@
"providers": ["ssh"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["ssh"],
"coverageNotes": "A macOS Electron client drives a Linux Docker SSH execution host. The six-spec suite passed ten enabled cases with clean worker exit (5.2m). The formerly skipped frozen-host input case now waits for recovered authority before sending input and passed four separate executions (one initial and three repetitions). The existing flooded-shell fixme remains an explicitly reproduced application gap. The bulk-open freeze reproduction runs in Linux headed CI with SwiftShader on Xvfb: headless Linux schedules idle animation frames about 1s apart, invalidating the foreground interaction measurement. Original uninstrumented five-pane workload passed all ten repetitions with zero retries/skips in 6.6m; bulk-open lag 79.3–147.8ms and interaction 127.1–155.9ms, unchanged 2500ms/5000ms budgets. Run 34037669843, head f25eab3fd7d723509ced026633f80b193a139b76, excludes unmerged replay-input application fix #19075. Deterministic remote Codex fixture validation passed three normal restores and three forced reconnects with zero retries on merged main plus the replay probe correction (run 34050117471). The original forced-reconnect probe missed nonempty replay returned in pty:spawn reattach replies. Routine coverage now includes both modes by default; real Codex service execution remains opt-in.",
"coverageNotes": "A macOS Electron client drives a Linux Docker SSH execution host. The six-spec suite passed ten enabled cases with clean worker exit (5.2m). The formerly skipped frozen-host input case now waits for recovered authority before sending input and passed four separate executions (one initial and three repetitions). The existing flooded-shell fixme remains an explicitly reproduced application gap. The bulk-open freeze reproduction runs in Linux headed CI with SwiftShader on Xvfb: headless Linux schedules idle animation frames about 1s apart, invalidating the foreground interaction measurement. Original uninstrumented five-pane workload passed all ten repetitions with zero retries/skips in 6.6m; bulk-open lag 79.3–147.8ms and interaction 127.1–155.9ms, unchanged 2500ms/5000ms budgets. Run 34037669843, head f25eab3fd7d723509ced026633f80b193a139b76, excludes unmerged replay-input application fix #19075. Deterministic remote Codex fixture validation passed three normal restores and three forced reconnects with zero retries on merged main plus the replay probe correction (run 34050117471). The original forced-reconnect probe missed nonempty replay returned in pty:spawn reattach replies. Routine coverage now includes both modes by default; real Codex service execution remains opt-in. The added five-pane input spec passed in Linux CI run 34033353595, and diagnostic run 34034754815 reproduced real input loss during scrollback replay; application fix #19075 (98b0c329ff3) has since merged and this spec now guards it.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/18018",
"https://github.com/stablyai/orca/pull/18546",
@@ -18321,7 +18510,8 @@
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 --repeat-each=10",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-codex-display-artifacts-repro.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1"
],
"testFiles": [
"tests/e2e/ssh-docker-transport-drop-recovery.spec.ts",
@@ -18333,7 +18523,8 @@
"tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
"tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts",
"tests/e2e/ssh-codex-display-artifacts-repro.spec.ts",
"tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts"
"tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts",
"tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts"
],
"assertionRefs": [
{
@@ -18385,6 +18576,12 @@
"five flooding SSH panes remain below unchanged 2500ms soft and 5000ms hard freeze budgets during bulk reopen and two double-animation-frame view changes"
]
},
{
"file": "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts",
"assertions": [
"five distinct SSH PTYs acknowledge actual keyboard input after two rendered hide/reopen cycles while all five producers flood"
]
},
{
"file": "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts",
"assertions": [
@@ -18433,7 +18630,7 @@
},
"flakeHistory": {
"status": "flaky",
"evidence": "Baseline: ten enabled tests passed, two fixme skipped, worker teardown timed out (7.3m). After pipe cleanup: ten passed and worker exited cleanly (5.2m); two half-open repeats passed (1.7m). The formerly skipped thaw-input case failed before its recovered-authority wait and passed 1+3 executions afterward (56.9s + 2.6m). Flood failed both its original input oracle and a strengthened producer-completion oracle after recovery."
"evidence": "Baseline: ten enabled tests passed, two fixme skipped, worker teardown timed out (7.3m). After pipe cleanup: ten passed and worker exited cleanly (5.2m); two half-open repeats passed (1.7m). The formerly skipped thaw-input case failed before its recovered-authority wait and passed 1+3 executions afterward (56.9s + 2.6m). Flood failed both its original input oracle and a strengthened producer-completion oracle after recovery. Five-pane input diagnostics additionally failed 1/5 in run 34034754815: the intended focused PTY emitted the full input, the replay guard discarded 31 characters, and the remote ACK contained exactly the remaining suffix. PR #19075 addresses that application bug; passing repetitions alone do not establish its resolution."
},
"redGreenEvidence": {
"status": "partial",
@@ -18449,6 +18646,7 @@
"Collect CI runtime and flake history plus product red/green evidence before blocking."
],
"knownGaps": [
"Five-pane simultaneous flood input was reproduced as a real application bug in run 34034754815 (replay discarded the first 31 characters of correctly focused keyboard input); fix #19075 (98b0c329ff3) merged and the spec now guards it, but the retries: 0 Docker SSH lane is the only repetition evidence against the merged fix so far. Freeze performance coverage was restored separately in #19081, with its isolated headless timer-lag outlier still documented.",
"The disconnected 48MB flood still loses its relay channel: original post-flood input marker failed in 60s, and waiting for the finite producer completion marker failed in 120s. It remains an explicit #18018 fixme reproduction; frozen-host input is re-enabled after four successful runs.",
"Linux headed CI covers the bulk-open freeze reproduction; Windows clients, WSL, folder workspaces, paired runtimes and live agent CLIs are not covered by that result.",
"Some legacy assertions inspect terminal serialization or backing state rather than rendered DOM; no blanket visual coverage claim.",
@@ -18549,27 +18747,13 @@
"protection": "partial",
"owner": "browser-runtime",
"layer": "electron-packaged",
"surfaces": [
"paired browser placement"
],
"platforms": [
"linux",
"macos",
"windows"
],
"providers": [
"paired-runtime"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"paired-runtime"
],
"surfaces": ["paired browser placement"],
"platforms": ["linux", "macos", "windows"],
"providers": ["paired-runtime"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Published Linux 1.4.188 desktop against current source in both directions; scheduled weekly and manually runnable. No required PR check.",
"motivatingLinks": [
"https://github.com/stablyai/orca/actions/runs/34069063016"
],
"motivatingLinks": ["https://github.com/stablyai/orca/actions/runs/34069063016"],
"invariant": "A paired client and host without client-hosted browser capabilities retain server-hosted browser placement across supported version skew.",
"oracle": "Require both existing named browser placement scenarios to pass three times with one attempt, zero skips, zero failures, and no report errors.",
"commands": [
@@ -18593,9 +18777,7 @@
},
{
"file": "config/scripts/verify-packaged-browser-participation.test.mjs",
"assertions": [
"reject missing, substituted, skipped and retried scenarios"
]
"assertions": ["reject missing, substituted, skipped and retried scenarios"]
},
{
"file": "config/scripts/packaged-browser-lane-contract.test.mjs",
@@ -18650,26 +18832,13 @@
"protection": "partial",
"owner": "terminal-input",
"layer": "electron-native-ime-e2e",
"surfaces": [
"native Hangul composition",
"Wayland terminal input"
],
"platforms": [
"linux"
],
"providers": [
"local"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"local"
],
"surfaces": ["native Hangul composition", "Wayland terminal input"],
"platforms": ["linux"],
"providers": ["local"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local"],
"coverageNotes": "Ubuntu 22.04 nested GNOME and IBus Hangul drive three complete native executions in GitHub Actions. GNOME owns IBus; daemon and CLI share its default config discovery path.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/19174"
],
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19174"],
"invariant": "Typing d k 1 Return through native IBus Hangul delivers exactly 아1 followed by newline without missing, duplicate, or reordered characters.",
"oracle": "Three executions each assert three exact UTF-8 PTY lines. Verify the exact Playwright title, zero skips/retries, each individual native composition receipt, and the nested launch Wayland flag.",
"commands": [
@@ -18685,15 +18854,11 @@
"assertionRefs": [
{
"file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts",
"assertions": [
"a digit typed right after a Hangul syllable reaches the pty"
]
"assertions": ["a digit typed right after a Hangul syllable reaches the pty"]
},
{
"file": "config/scripts/terminal-ime-e2e-workflow.test.mjs",
"assertions": [
"runs native Wayland independently with CJK fonts and retained evidence"
]
"assertions": ["runs native Wayland independently with CJK fonts and retained evidence"]
}
],
"evidenceRuns": [
@@ -1,6 +1,6 @@
export async function configureRendererScaleFixture(page, options, repoPath) {
return page.evaluate(
({ agentsPerWorktree, lineageDepth, repoPath }) => {
({ agentsPerWorktree, subagentsPerAgent, lineageDepth, repoPath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
@@ -98,7 +98,18 @@ export async function configureRendererScaleFixture(page, options, repoPath) {
{
state: 'working',
prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`,
agentType
agentType,
...(subagentsPerAgent > 0
? {
subagents: Array.from({ length: subagentsPerAgent }, (_, index) => ({
id: `child-${index}`,
state: 'working',
startedAt: fixtureNow,
agentType,
description: `Subagent ${worktreeIndex + 1}.${agentIndex + 1}.${index + 1}`
}))
}
: {})
},
agentType,
{ updatedAt: fixtureNow, stateStartedAt: fixtureNow },
@@ -116,10 +127,16 @@ export async function configureRendererScaleFixture(page, options, repoPath) {
expandedLineageGroups: lineageParentIds.size,
agentsPerWorktree,
seededAgentRows,
seededSubagentRows: seededAgentRows * subagentsPerAgent,
orderedWorktreeIds: worktrees.map((worktree) => worktree.id)
}
},
{ agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath }
{
agentsPerWorktree: options.agentsPerWorktree,
subagentsPerAgent: options.subagentsPerAgent ?? 0,
lineageDepth: options.lineageDepth,
repoPath
}
)
}
@@ -168,9 +168,10 @@ describe('orchestration kernel', () => {
expect(kernel).toContain(
'`projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv'
)
expect(kernel).toContain(
'An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false is informational, not a command to re-run: keep waiting with `check --wait`'
)
// Unverifiable workers can still owe release; the guide must explain the action itself.
expect(kernel).toContain('A `none` `nextAction` has no argv to run')
expect(kernel).toContain('read `liveness.reason` and keep waiting with `check --wait`')
expect(kernel).toContain('Absence never earns an argv; settlement and pending work still do')
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
})
+8 -2
View File
@@ -1,4 +1,3 @@
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
@@ -369,7 +368,14 @@ function matchesPrefix(file, prefixes) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
// Why streamed, not readFileSync(0): a single read of fd 0 throws EAGAIN once the writer
// outgrows the 64 KB pipe buffer, which a stale PR base.sha reaches easily.
let input = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
input += chunk
}
const files = input.split(/\r?\n/).filter(Boolean)
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+49 -1
View File
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -353,6 +353,54 @@ describe('per-job path classification', () => {
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
// A long-lived PR whose base.sha has gone stale diffs thousands of files, so the writer
// outruns one pipe buffer. A single fd-0 read then returns early, breaks the writer's pipe,
// and still exits 0 -- emitting no pairs at all, which silently skips every lane.
it('classifies a path that arrives after the first pipe buffer', async () => {
const filler = Array.from(
{ length: 12_000 },
(_, index) => `docs/reference/generated-placeholder-${index}.md`
)
const input = `${[...filler, 'config/patches/xterm-upstream.json'].join('\n')}\n`
expect(input.length).toBeGreaterThan(64 * 1024)
const child = spawn(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
let brokePipe = false
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.stdin.on('error', (error) => {
brokePipe ||= error.code === 'EPIPE'
})
const exitCode = await new Promise((resolvePromise) => {
child.on('close', resolvePromise)
let offset = 0
const step = () => {
if (offset >= input.length) {
child.stdin.end()
return
}
child.stdin.write(input.slice(offset, offset + 64 * 1024))
offset += 64 * 1024
setTimeout(step, 20)
}
step()
})
expect(stderr).not.toContain('EAGAIN')
expect(brokePipe).toBe(false)
expect(exitCode, stderr).toBe(0)
expect(stdout).toContain('should_run=true\n')
expect(stdout).toContain('xterm_patch_sync=true\n')
})
})
describe('PR Checks skip wiring', () => {
@@ -168,6 +168,9 @@ describe('PR E2E gate contract', () => {
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain(
'. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts"'
)
expect(changedRun.run).toContain('. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts"')
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
+1
View File
@@ -63,6 +63,7 @@ const result = spawnSync(
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
+1 -1
View File
@@ -34,7 +34,7 @@ src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.
src/main/runtime/orca-runtime-create-terminal.ts
src/main/runtime/orca-runtime-deliver-pending-messages.ts
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
src/main/runtime/orca-runtime-fence-automation-owner.ts
src/main/runtime/orca-runtime-automation-operations.ts
src/main/runtime/orca-runtime-file-commands.ts
src/main/runtime/orca-runtime-fit-override-listeners.ts
src/main/runtime/orca-runtime-focus-terminal.ts
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 42m">
<title>downloads: 42m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 43m">
<title>downloads: 43m</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="90" y="15" fill="#010101" fill-opacity=".3">42m</text>
<text x="90" y="14">42m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">43m</text>
<text x="90" y="14">43m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -87,13 +87,25 @@ bundled prototype, the fixture without seeded agents fell from 8,518 listeners
to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare
against the census in "Baseline on `main`", which the harness reports directly.
### Share working-spinner phase without per-element animation queries
### Share working-spinner phase without synchronous mount queries
Working rows keep the existing compositor-driven CSS animation and shared
visual phase. Each mount derives one negative animation delay from the document
timeline instead of querying `getAnimations()` and mutating the animation start
time. This removes per-row Web Animations setup from dense status transitions
without adding a JavaScript animation clock.
visual phase. `animationstart` anchors each animation to document time zero.
Deferring the animation query until that event avoids a synchronous style flush
at each mount and restores the shared phase after `display:none` or a motion
preference change. A negative mount-time delay cannot preserve that phase after
an animation restarts.
### Bound spinner animation overhead
Working rings keep compositor-driven CSS animation, but repeat the animation
once per day rather than once per second. The same 12 steps per second now
avoid recurring React animation-iteration dispatch. The existing stationary
wrapper and ring rendering stay unchanged. Offscreen containment was evaluated
and rejected after a pixel regression at low zoom on 1x displays.
The history, isolated measurements, full-app workspace/agent/subagent benchmark,
and limitations are documented in [Spinner rendering performance](./spinner-rendering-performance.md).
### Fold a burst in event order
@@ -0,0 +1,203 @@
# Spinner rendering performance
## ELI5
Imagine a wheel that tells the front desk every time it completes a lap. The
front desk is also handling your typing. CSS already turns the wheel for us,
but React still receives its once-per-second lap notifications.
We put a day's worth of laps into one animation. The wheel moves at the same
speed, while sending one lap notification a day. Drawing visible wheels still
costs something. This removes recurring bookkeeping from the input thread; it
does not make rendering or the rest of Orca free.
## How this builds on earlier changes
| Change | What it achieved | Remaining cost |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| [#9380](https://github.com/stablyai/orca/pull/9380): shared JavaScript clock | Reduced frame-pipeline CPU in the original one-agent measurement | Wrote each spinner's style 12 times per second on the input thread |
| [#12359](https://github.com/stablyai/orca/pull/12359): compositor CSS rotation | Removed those recurring JavaScript style writes; fixed the reported typing regression | React still receives CSS iteration events |
| [#13987](https://github.com/stablyai/orca/pull/13987): synchronize on animationstart | Avoided a synchronous style query at every mount | Steady-state animation overhead stayed the same |
| This change | Preserves both later fixes and removes almost all iteration boundaries | Compositing, other app work, mount/reveal work, and a daily iteration boundary remain |
The historical measurements in #12359 reported 41 rings causing about 490 style
writes per second, with typing input-delay p90 of 363 ms versus 19 ms when those
writes stopped. Those are historical production measurements, not numbers from
this benchmark or a direct comparison with today's app.
## Implementation
The production change is entirely in CSS. `AgentWorkingSpinner`, its callers,
markup, border, animation-start handler, and reduced-motion behavior stay the
same. No DOM node, pseudo-element, containment boundary, timer, observer, or
JavaScript animation loop is added.
The transform travels 86,400 turns in 86,400 seconds with 1,036,800 steps: exactly
one revolution and 12 steps per second. `animationstart` sets `startTime = 0` as
before, preserving shared phase after mount and animation restart. The step
count is a timing-function parameter, not a million-entry keyframe list.
React installs delegated `animationiteration` listeners even when the component
has no iteration handler. A native 2.2-second trace of 200 isolated rings counted
400 iteration events and 800 JavaScript calls before the change, versus zero of
either with the long cycle. That trace installed no animation-event listener.
These are event dispatches, not component rerenders or 400 separate OS wakeups.
## Full-app benchmark
The opt-in Playwright benchmark launches a fresh, hidden Orca app for each
scenario. It creates real Git workspaces and seeds working statuses through the
existing renderer fixture, including in-process subagent data. It renders the
normal sidebar, virtualizer, lineage, agent rows, tabs, and terminal.
| Scenario | Git workspaces | Root agents | Subagents | Mounted / visible rings | Layout |
| ------------- | -------------: | ----------: | --------: | ----------------------: | -------------------------------------------- |
| `one-agent` | 1 | 1 | 0 | 3 / 3 | One working agent |
| `one-family` | 1 | 2 | 4 | 8 / 8 | All family rows expanded |
| `200-flat` | 200 | 400 | 800 | 162 / 15 | Normal virtualization; 23 workspaces mounted |
| `200-lineage` | 200 | 400 | 800 | 1,401 / 15 | Expanded lineage; all 200 workspaces mounted |
Measurement-only styles switch between the original one-second cycle and the
new long cycle on the same elements. The real React root, callers, status data,
and app stay the same. The reported run alternates A/B and B/A, with four
ten-second CPU samples per variant after warmup. CPU samples use cumulative
Electron process CPU and CDP main-thread task/script/style/layout metrics. No
renderer polling, screenshots, or benchmark iteration listeners run during
those CPU windows. No samples are discarded.
Typing is measured separately using the existing paced terminal-typing probe:
64 keys at 113 ms cadence, twice per variant, after two seconds of warmup with
status traffic. Status updates arrive in groups of up to eight every 200 ms.
Keys pass through the DOM, real PTY, and xterm. A sidecar timestamps arrival at
the PTY, and a bounded terminal-buffer scan observes each echo. Missing input
or echoes fail the benchmark. Echo measurements include the 10 ms scan interval;
they do not measure native display presentation. Native animation traces also
run separately from CPU and typing samples.
The statuses are deterministic test data, not hundreds of paid model sessions.
The test exercises UI cost under agent-status traffic, not the compute or network
cost of model inference, SSH traffic, or hundreds of streaming PTYs.
## Results
CPU values are medians of four samples. "CPU ms/s" means milliseconds of
processor time used in one wall-clock second: 100 ms/s is about 10% of one CPU
core. Renderer + GPU-process CPU includes their other app work and CPU used by
the graphics process; it is not GPU hardware utilization or whole-machine CPU.
The main thread handles input and is included in renderer CPU, not extra work.
Echo p90 means 90% of sampled keys were observed within that time; ranges show
the two runs, not confidence intervals. No keys or echoes were missing.
| Scenario | Renderer + GPU CPU ms/s, old → new | Main-thread ms/s, old → new | Echo p90 ms, old → new |
| ------------- | ---------------------------------: | --------------------------: | ---------------------- |
| `one-agent` | 37.0 → 38.2 | 5.4 → 3.5 | 19 → 18–19 |
| `one-family` | 46.2 → 44.6 | 7.8 → 4.4 | 17–19 → 18–19 |
| `200-flat` | 141.6 → 122.8 | 28.2 → 16.3 | 26–28 → 26–28 |
| `200-lineage` | 324.6 → 295.6 | 140.8 → 70.0 | 159–239 → 93–160 |
The consistent gain is less main-thread work: about 35%, 43%, 42%, and 50%
less in these four scenarios. Native 2.2-second traces counted 6, 16, 324, and
2,802 iteration events before, and zero in each new variant, without adding an
iteration listener. That avoided work also exists in Orca itself, independently
of the isolated fixture and CPU noise.
Total CPU was roughly unchanged in the one-worktree cases. In this run it fell
13% with normal virtualization and 9% with expanded lineage; seven of eight
paired large-case CPU samples favored the change. These percentages are not
universal: a shorter three-variant ablation measured flat-list CPU at 89.0 ms/s before and
108.0 ms/s with the long cycle, while main-thread time still fell from 26.3 to
17.4 ms/s. The repeatable main-thread reduction is stronger evidence than a
single total-CPU percentage.
Typing was similar in the small and flat-list cases. Expanded-lineage echo p90
improved in the final run, but a shorter ablation had similar before/after
latencies. No general typing speedup or statistical non-regression guarantee
is established by these short experiments.
### All CPU samples
Values are rounded to one decimal and listed by round, with no outliers removed.
The first new small-case samples were higher than their paired baselines; they
remain included. CPU and typing were sampled separately.
| Scenario | Version | Renderer + GPU CPU ms/s | Main-thread ms/s |
| ------------- | ------- | -------------------------- | -------------------------- |
| `one-agent` | Old | 37.8, 36.2, 26.2, 39.6 | 6.5, 5.2, 4.8, 5.7 |
| `one-agent` | New | 53.3, 35.8, 37.5, 39.0 | 6.7, 2.8, 3.0, 4.1 |
| `one-family` | Old | 46.3, 46.1, 47.9, 44.6 | 7.8, 7.6, 9.8, 7.7 |
| `one-family` | New | 53.8, 45.4, 42.5, 43.8 | 6.8, 4.5, 3.1, 4.3 |
| `200-flat` | Old | 142.4, 140.8, 147.0, 136.5 | 28.3, 28.1, 32.2, 27.0 |
| `200-flat` | New | 122.9, 97.2, 122.7, 126.7 | 19.2, 10.7, 16.6, 16.0 |
| `200-lineage` | Old | 317.9, 385.2, 315.9, 331.2 | 134.4, 159.6, 133.9, 147.3 |
| `200-lineage` | New | 318.6, 256.9, 296.5, 294.7 | 89.2, 60.8, 71.6, 68.3 |
## Reproduce
```sh
ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --sample-ms=5000
ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --verify-only --scale-factor=1
ORCA_BACKGROUND_LAUNCH=1 pnpm bench:spinners --verify-only --scale-factor=2
ORCA_BACKGROUND_LAUNCH=1 ORCA_SPINNER_BENCH=1 ORCA_SPINNER_KEYS=64 \
pnpm test:e2e spinner-workspace-perf.spec.ts --workers=1
```
The full-app command rebuilds in `e2e` mode. For a fresh build already made with
`pnpm exec electron-vite build --mode e2e`, `SKIP_BUILD=1` reuses it. Do not reuse
an old launch-policy build. `ORCA_SPINNER_SAMPLE_MS`, `ORCA_SPINNER_ROUNDS`,
`ORCA_SPINNER_KEYS`, `ORCA_SPINNER_KEY_CADENCE_MS`, `ORCA_SPINNER_VARIANTS`, and
`ORCA_SPINNER_OUTPUT` control the experiment. `ORCA_SPINNER_CPU=0` repeats only
typing; `--grep one-agent` selects one scenario. Reports, native traces, typing
sidecars, and CDP screenshots are written under `.bench-fixtures/`. Run one
benchmark at a time, without concurrent builds or tests.
The optional `contained` variant retains the rejected offscreen experiment for
ablation. It adds `content-visibility:auto` to the existing wrapper through
measurement-only styles. It is not enabled in production or the default
benchmark comparison.
## Visual and behavioral checks
Both 1x and 2x display-density checks passed 720 ring comparisons each: 6/8 px
rings, light/dark themes, supported zoom extremes, all 12 phases, long elapsed
times, and the daily wrap. The comparison pauses each animation and sets its
`currentTime`, so the long-elapsed and daily-wrap cases exercise the deterministic
style path rather than a running compositor animation. Against that path the
tolerance is one channel level for floating-point antialias rounding. A running
animation at multi-hour ages can differ by a few channels on the ring edge — a
fraction-of-a-pixel antialias difference at large accumulated angles, not a phase
or shape change. Checks also cover shared phase, reduced motion, initial offscreen
reveal, repeated scroll-away/reveal, and `display:none` restoration.
## Limits and rejected approaches
Adding `content-visibility:auto` to the existing stationary wrapper saved more
CPU at large mounted counts, but a 1x display check found a one-pixel shift at
the minimum UI zoom. That containment change is excluded. A previous
pseudo-element version also regressed typing latency in the virtualized list.
Neither prototype's CPU or typing numbers describe the final patch.
An initial typing run used a 100 ms key cadence, which can repeatedly align with
200 ms status bursts. Follow-up runs use 113 ms, more keys, and two seconds of
warmup under status traffic. This reduces timing bias; it does not excuse a
regression. CPU measurements run separately and do not depend on key cadence.
An early isolated test suggested a 31% process-CPU reduction that a longer audit
did not reproduce. The longer isolated audit measured original 104.04 versus
long-cycle 92.32 CPU ms/s, and main-thread 10.08 versus 0.24 ms/s. A fixture with
every ring far offscreen and containment enabled could also approach idle; that
is not representative of Orca with visible animations. Neither result justifies
claiming "free spinners" or a universal CPU percentage. Virtualized, unmounted
rows already cost nothing, and this patch does not add offscreen culling.
All local measurements use an Apple M4 (10 cores), macOS, Electron 43.4.1 /
Chromium 150.0.7871.224. Native windows stay hidden and unfocused;
benchmark-only settings disable background throttling to exercise the frame
pipeline. These are not visible-window power measurements. No battery benefit
is established. Linux/Windows need their own runtime measurements. The
renderer-only change does not alter SSH execution, wire data, status semantics,
Git operations, or folder-workspace ownership.
Animated PNGs, masks, layer promotion, CSS sprites, individual `rotate`, and
containment on the rotating element were also explored. Shared images added
raster work and regressed the single-ring case; sprites reintroduced per-frame
style work. They did not meet the appearance and responsiveness requirements.
+31 -31
View File
@@ -31,12 +31,12 @@ administrators can do about it.
Four independent evidence clusters, from six incidents:
| Cluster | Incidents | Evidence |
| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| Cluster | Incidents | Evidence |
| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
**Execution + Collection**, and a description reading _"Screenshots were taken
@@ -143,11 +143,11 @@ PEB fallback to reinstate it — a hooked `ntdll` answering
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
Because the property is the *absence* of an import, it is checkable on the
Because the property is the _absence_ of an import, it is checkable on the
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
bearing because the published tarball ships a *loadable* prebuilt built from
bearing because the published tarball ships a _loadable_ prebuilt built from
unpatched source, so "it required cleanly" is not evidence.
What to declare to administrators is now one
@@ -180,7 +180,7 @@ Three sites are named in the incident analysis:
`src/shared/setup-agent-sequencing.ts`,
`src/shared/windows-cmd-runner-delayed-launch.ts` and
`src/shared/windows-interactive-login-spawn.ts` each dropped
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script *files*,
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script _files_,
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
pattern to copy rather than restoring the switch — the switch loses to a GPO
@@ -192,7 +192,7 @@ What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
`src/main/agent-hooks/runtime-home-hook-command.ts`,
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
— that last one only as a *fallback* since #18875, see below),
— that last one only as a _fallback_ since #18875, see below),
`src/main/runtime/windows-default-route-interfaces.ts`,
`src/main/runtime/orchestration/setup-completion-signal.ts`,
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
@@ -224,7 +224,7 @@ denies the analyser the payload it would otherwise clear.
The hook launcher is prior art worth knowing about. #16003 measured, on a
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
payload — `exit 0` was denied too. The fix was to stop *spelling* the flags:
payload — `exit 0` was denied too. The fix was to stop _spelling_ the flags:
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
#16576, the execution policy bypass moved in-payload as a process-scope
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
@@ -247,7 +247,7 @@ a quoted token, each `%` is broken with `"^%"`.
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
"e^%F^%g", "h"]` — the `&` truncated the argument *and* ran the remainder as a
"e^%F^%g", "h"]` — the `&` truncated the argument _and_ ran the remainder as a
command.
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
@@ -259,7 +259,7 @@ obfuscated-command-line detector is tuned on.
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
terminal multiplexer for coding agents _is_. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
@@ -267,7 +267,7 @@ terminal multiplexer for coding agents *is*. `reg.exe` appears from
Nothing here is avoidable in principle. What is controllable is depth and
breadth: every interpreter hop between Orca and the thing the user asked for adds
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
*shorten the interpreter chain* rather than to hide a window.
_shorten the interpreter chain_ rather than to hide a window.
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
@@ -295,7 +295,7 @@ That last clause is the standing assumption of this change, and it is worth
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
any host that is one of the first three. Claude Code itself is a Git Bash host on
native Windows. What no one here has verified is which host a *compat consumer*
native Windows. What no one here has verified is which host a _compat consumer_
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
for exactly that). If one of them spawns hook strings through Windows PowerShell
@@ -318,12 +318,12 @@ then captures the screen through `Graphics.CopyFromScreen`.
That is four separate high-signal behaviours stacked in one process:
| Behaviour | How it is scored |
| ----------------------------------------------- | ---------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
| Behaviour | How it is scored |
| --------------------------------------------- | ------------------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
The bottom two rows are the two the incident text named directly, and they are
also the two a persistent runtime host would remove: a long-lived helper compiles
@@ -381,16 +381,16 @@ changed. Check the code before relying on it.
The checklist. On Windows, do not reach for:
| Don't | Instead |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
| Don't | Instead |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
@@ -407,7 +407,7 @@ Two framing rules that outlast the table:
This is the single most important operational point, and it is the one most
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
Defender Antivirus path exclusions suppress *scan* detections; they do not
Defender Antivirus path exclusions suppress _scan_ detections; they do not
suppress EDR behavioural alerts the same way. Adding
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
incidents to stop will not work.
+17 -17
View File
@@ -64,10 +64,10 @@ identity scan opens nothing.
So the module exposes two snapshots, and the row types differ so a cheap caller
cannot read what its flag set did not pay for:
| reader | row type | flags | per-process handles |
| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
| reader | row type | flags | per-process handles |
| ------------------------------------------ | --------------------------- | ---------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
`Memory` is requested by neither. Nothing reads a working set off this table —
`windows-process-resource-collector.ts` runs its own sweep because it needs
@@ -103,7 +103,7 @@ only under concurrency.
Nothing else in this module prevents that. Each snapshot cache single-flights
only within itself (`inFlight` is a closure per reader), and the wedge set
latches only *after* a read misses its 3 s deadline, so through the healthy
latches only _after_ a read misses its 3 s deadline, so through the healthy
~12 ms of a scan neither excludes the other. Overlap is the normal state rather
than an edge case: other panes keep polling detailed at 750 ms while a teardown
takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh
@@ -166,15 +166,15 @@ through `toIdentityRow`, so an identity row carries no command line on any host.
### Which callers need which
| caller | reads | flag set |
| --------------------------------------------- | ------------------ | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
| caller | reads | flag set |
| ------------------------------------------ | --------------------------------- | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and
`name`, which the identity set answers, but it calls the detailed reader. On a
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries six changes.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -368,10 +368,10 @@ on any other OS keeps using the scan.
to Unix ms; a process that denies the handle is emitted with the field
absent, never zero, because callers must be able to tell "cannot identify"
from a timestamp.
5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
6. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
compiled binary understands, and `lib/index.js` re-exports it.
Why a fifth hunk and not just the enum: unlike `node-pty`, this package
Why a separate hunk and not just the enum: unlike `node-pty`, this package
publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
+2 -3
View File
@@ -30,8 +30,7 @@ import { Callout } from '@/components/docs/prose'
[installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
</li>
<li>
**Linux:**
AppImage
**Linux:** AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
@@ -131,7 +130,7 @@ On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `or
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that _during_ startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
+11 -3
View File
@@ -129,19 +129,23 @@ Install Orca and its bundled CLI on the server, then run:
<Callout title="On Linux, start it with orca-ide serve">
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
while it is starting, so that shim can never be the command that starts the server. Read
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
[Install → Linux](/docs/install#linux).
while it is starting, so that shim can never be the command that starts the server. Read `orca
serve` as `orca-ide serve` throughout this page when the host is Linux. See [Install →
Linux](/docs/install#linux).
</Callout>
```bash
orca serve --pairing-address <server-tailscale-ip-or-hostname>
# Linux
orca-ide serve --pairing-address <server-tailscale-ip-or-hostname>
```
For example:
```bash
orca serve --pairing-address 100.64.1.20
# Linux
orca-ide serve --pairing-address 100.64.1.20
```
The command:
@@ -157,6 +161,8 @@ Add `--port 6768` when a firewall, tunnel, or service definition requires a fixe
```bash
orca serve --port 6768 --pairing-address 100.64.1.20
# Linux
orca-ide serve --port 6768 --pairing-address 100.64.1.20
```
Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup.
@@ -167,6 +173,8 @@ For the Orca mobile app, request a mobile-scoped QR code and link:
```bash
orca serve --pairing-address 100.64.1.20 --mobile-pairing
# Linux
orca-ide serve --pairing-address 100.64.1.20 --mobile-pairing
```
Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan the terminal QR code or paste the printed link.
+8 -8
View File
@@ -2321,11 +2321,11 @@ packages:
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -6854,12 +6854,12 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
brace-expansion@1.1.16:
brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.8:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -8764,11 +8764,11 @@ snapshots:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.8
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.16
brace-expansion: 1.1.18
minimist@1.2.8: {}
-30
View File
@@ -20,36 +20,6 @@
"rules": {
"max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/source-control/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/transport/rpc-client.ts"],
"rules": {
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 402, "skipBlankLines": true, "skipComments": true }]
}
}
]
}
+9 -9
View File
@@ -3075,12 +3075,12 @@ packages:
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
'@xmldom/xmldom@0.9.12':
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
engines: {node: '>=14.6'}
'@xterm/addon-unicode11@0.10.0-beta.300':
@@ -9072,13 +9072,13 @@ snapshots:
'@expo/plist@0.5.3':
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
'@expo/plist@0.5.4':
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
@@ -10574,9 +10574,9 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.8.15': {}
'@xmldom/xmldom@0.9.10': {}
'@xmldom/xmldom@0.9.12': {}
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)':
dependencies:
@@ -14451,7 +14451,7 @@ snapshots:
plist@3.1.1:
dependencies:
'@xmldom/xmldom': 0.9.10
'@xmldom/xmldom': 0.9.12
base64-js: 1.5.1
xmlbuilder: 15.1.1
+4 -172
View File
@@ -44,12 +44,6 @@ function GateConsumer() {
return createElement('GateStatus', null, hostCapabilities.join(','))
}
// Separate from GateStatus so the capability assertions keep their exact rendered shape.
function VerifiedConsumer() {
const { compatVerified } = useHostProtocolGates()
return createElement('GateVerified', null, compatVerified ? 'verified' : 'unverified')
}
// Counts mounts so a test can prove the routes were never torn down, which presence alone can't.
const probeMounts = { count: 0 }
function MountProbe() {
@@ -63,13 +57,7 @@ function gateElement() {
return createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement(
'HostContent',
null,
createElement(GateConsumer),
createElement(VerifiedConsumer),
createElement(MountProbe)
)
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
)
}
@@ -166,90 +154,13 @@ describe('HostProtocolGate', () => {
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('serves every descendant capability read from the one status.get it issues', async () => {
const client = clientWithStatus({
protocolVersion: 5,
minCompatibleMobileVersion: 0,
capabilities: ['browser.screencast.v1', 'terminal.queryReplyInput.v1']
})
hostClient.current = { client, state: 'connected' }
renderer = await act(async () => {
const created = create(
createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement(GateConsumer),
createElement(GateConsumer)
)
)
await Promise.resolve()
return created
})
// Why: the session route used to run its own retrying status.get on top of this one, so a
// cold open cost two round trips for the same answer. Consumers now read the gate's copy.
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(client.sendRequest).toHaveBeenCalledWith('status.get')
const statuses = renderer.root.findAllByType('GateStatus')
expect(statuses).toHaveLength(2)
for (const status of statuses) {
expect(status.props.children).toBe('browser.screencast.v1,terminal.queryReplyInput.v1')
}
})
it('releases the cover on a failed status.get and upgrades when a retry lands', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new Error('status.get timed out'))
.mockResolvedValue({
ok: true,
result: { protocolVersion: 5, minCompatibleMobileVersion: 0, capabilities: ['late.v1'] }
})
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
renderer = await renderGate()
// Why: a wedged status.get must never trap the routes behind the cover, so the first miss
// settles conservative gates immediately — no capabilities, but a usable UI.
let output = renderedText(renderer)
expect(output).toContain('HostContent')
expect(output).not.toContain('Checking host compatibility')
expect(output).toContain('"type":"GateStatus","props":{},"children":null')
await act(async () => {
await vi.advanceTimersByTimeAsync(1_100)
})
// The probe kept retrying underneath, so the answer arrives without a remount.
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(renderedText(renderer)).toContain('late.v1')
expect(probeMounts.count).toBe(1)
vi.useRealTimers()
})
it('blocks a desktop that omits protocolVersion, so a pending verdict is not a formality', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
// Why this case and not just an explicit old version: evaluateCompat reads a missing
// protocolVersion as 0, so the everyday shape of an old desktop is a blocking one.
hostClient.current = {
client: clientWithStatus({ capabilities: [] }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca on your computer')
expect(output).not.toContain('HostContent')
})
it('renders the host UI while the host connection is still pending', async () => {
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
// Was: the routes were held back until status.get resolved, which serialised every route's
// own startup RPC behind this one round trip. They now mount immediately and are covered.
it('mounts host routes under the pending cover while status.get is still in flight', async () => {
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
const client = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
@@ -257,40 +168,9 @@ describe('HostProtocolGate', () => {
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Checking host compatibility')
expect(output).toContain('HostContent')
expect(probeMounts.count).toBe(1)
expect(client.sendRequest).toHaveBeenCalledOnce()
// Why: mounting early must not leak an unproven host's capabilities to the routes below;
// an empty join renders no children, so the consumer saw none.
expect(output).toContain('"type":"GateStatus","props":{},"children":null')
const overlay = renderer.root
.findAllByType('View')
.find((node) => node.props.accessibilityViewIsModal === true)
expect(overlay?.props.pointerEvents).toBe('auto')
})
it('unmounts the routes it mounted early when the verdict comes back blocked', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
let settle: ((response: unknown) => void) | null = null
const client = {
sendRequest: vi.fn().mockReturnValue(
new Promise((resolve) => {
settle = resolve
})
)
} as unknown as RpcClient
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
await act(async () => {
settle?.({ ok: true, result: { protocolVersion: 5, minCompatibleMobileVersion: 999 } })
await Promise.resolve()
})
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).not.toContain('HostContent')
expect(probeMounts.count).toBe(0)
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
@@ -379,52 +259,4 @@ describe('HostProtocolGate', () => {
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
it('reports a rejected status.get as unverified, so failing open is not a passing verdict', async () => {
const sendRequest = vi
.fn()
.mockResolvedValue({ ok: false, error: { message: 'no such method' } })
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
renderer = await renderGate()
// Navigation still works: the host said no, and that must not lock the user out of the route.
const output = renderedText(renderer)
expect(output).toContain('HostContent')
expect(output).not.toContain('Checking host compatibility')
// Why: `compatVerdict` is `ok` here purely as a fallback. Nothing about this host was proven,
// so callers that write to it read this flag instead of the verdict.
expect(output).toContain('["unverified"]')
})
it('reports a passing status reply as verified', async () => {
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 0 }),
state: 'connected'
}
renderer = await renderGate()
expect(renderedText(renderer)).toContain('["verified"]')
})
it('stays unverified through a failed status.get and flips once a retry answers', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new Error('status.get timed out'))
.mockResolvedValue({
ok: true,
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
})
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('["unverified"]')
await act(async () => {
await vi.advanceTimersByTimeAsync(1_100)
})
// The retry landed, so the fallback is replaced by a real answer and writes are released.
expect(renderedText(renderer)).toContain('["verified"]')
vi.useRealTimers()
})
})
+33 -9
View File
@@ -22,26 +22,45 @@ export function useHostProtocolGates(): HostStatusGates {
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
// The routes mount as soon as the connection does, so their startup RPCs (session.tabs.list,
// terminal.list) fly alongside this status.get instead of queueing behind it; a blocked verdict
// then unmounts them and their answers are discarded.
export function HostProtocolGate({ hostId, children }: Props) {
const { client, state } = useHostClient(hostId)
const gates = useHostStatusGates({ hostId, client, connState: state })
const { compatVerdict, statusPending } = gates
const resolvedHostIdRef = useRef<string | null>(null)
const mountedHostIdRef = useRef<string | null>(null)
const hostKey = hostId ?? null
const resolvedNow = state === 'connected' && client !== null && !statusPending
const blocked = compatVerdict.kind === 'blocked'
const pending = statusPending && resolvedHostIdRef.current !== hostKey
const holdBack = pending && mountedHostIdRef.current !== hostKey
// Why: React can replay or discard a render, so the latch records committed outcomes only.
// Why: React can replay or discard a render, so the latches record committed
// outcomes only — a discarded children render must not count as mounted.
useEffect(() => {
if (resolvedNow) {
resolvedHostIdRef.current = hostKey
}
if (blocked) {
// Why: the block screen unmounts the routes, so a later pending window
// must not assume a live tree it can overlay.
mountedHostIdRef.current = null
} else if (!holdBack) {
mountedHostIdRef.current = hostKey
}
})
if (holdBack) {
// Why: nothing is mounted yet for this host, so hold the routes back entirely
// rather than letting them mount (and fire their connect RPCs) pre-verdict.
return (
<View style={styles.pending}>
<ActivityIndicator
color={colors.textSecondary}
accessibilityLabel="Checking host compatibility"
/>
</View>
)
}
if (blocked) {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
@@ -58,11 +77,10 @@ export function HostProtocolGate({ hostId, children }: Props) {
{children}
</View>
{pending ? (
// Why: cover the stack rather than unmounting it — unmounting for a pending status.get
// destroys in-flight nested navigation, and holding it back would serialise every route's
// startup RPC behind this one. Mount effects underneath run pre-verdict by design; they
// read capabilities from this gate, which reports none until the verdict lands, so every
// capability-dependent surface stays closed rather than guessing.
// Why: once the stack is mounted, unmounting it for a pending status.get destroys
// in-flight nested navigation, so cover it instead. Mount effects underneath still
// run — they wait for connState 'connected' and every capability-dependent call
// re-probes status.get itself, so nothing newer than the baseline fires here.
<View
style={styles.pendingOverlay}
// Why: the fill owns the hit test for in-tree views only — native-Modal-hosted
@@ -82,6 +100,12 @@ export function HostProtocolGate({ hostId, children }: Props) {
}
const styles = StyleSheet.create({
pending: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase
},
// Stays mounted across the overlay toggling so the routes below keep their identity.
host: {
flex: 1
@@ -7,7 +7,7 @@ const probe = vi.hoisted(() => ({
start: vi.fn()
}))
vi.mock('../transport/runtime-status-probe', () => ({
vi.mock('../transport/runtime-capability-probe', () => ({
startRuntimeCapabilityProbe: probe.start
}))
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type { RpcClient } from '../transport/rpc-client'
import { startRuntimeCapabilityProbe } from '../transport/runtime-status-probe'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
// Why: source the capability string from the shared contract so a host bump can never
// silently drift from the mobile probe.
@@ -2,8 +2,6 @@ import { Animated, View, Text, Pressable, ActivityIndicator } from 'react-native
import { saveTerminalTextScale } from '../storage/preferences'
import { MobileBrowserPane } from '../browser/MobileBrowserPane'
import { TerminalPaneView } from './TerminalPaneView'
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
import { MOBILE_SESSION_TAB_BAR_HEIGHT } from './mobile-session-frame-styles'
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
@@ -77,30 +75,15 @@ export function MobileSessionActiveContent({
isPendingTerminalRecoveryParked,
retryPendingTerminalRecovery,
showLoadingState,
measurePrewarmViewport,
visibleTabs,
showEmptyState,
keyboardLift,
activeTerminalKeyboardLift,
toastAnimatedStyle,
createTabBusy
} = controller
// Why the same list the header gates on: an unmounted tab bar gives the content row its band
// back, so the pre-warm would measure a taller box than the pane ever gets. Reading the header's
// own condition keeps the two from drifting when what counts as a visible tab changes.
const prewarmReservedTabBarHeight = visibleTabs.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT
return showLoadingState ? (
// Why: the engine boots inside the real terminal frame while the startup RPCs are still in
// flight, so the first pane inherits a warm WebView and a measured viewport (see prewarm).
<View style={styles.terminalFrame}>
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
<TerminalEnginePrewarm
reservedTabBarHeight={prewarmReservedTabBarHeight}
textScale={terminalTextScale}
onEngineMeasured={measurePrewarmViewport}
/>
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmptyState ? (
<View style={styles.emptyState}>
@@ -188,35 +171,25 @@ export function MobileSessionActiveContent({
)}
</View>
) : activePendingTerminalTab ? (
<View style={styles.terminalFrame}>
<View style={styles.emptyState}>
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [
styles.createButton,
pressed && styles.newTerminalButtonPressed
]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
<TerminalEnginePrewarm
reservedTabBarHeight={prewarmReservedTabBarHeight}
textScale={terminalTextScale}
onEngineMeasured={measurePrewarmViewport}
/>
<View style={styles.emptyState}>
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [styles.createButton, pressed && styles.newTerminalButtonPressed]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
) : (
<View
@@ -1,238 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
const engine = vi.hoisted(() => ({
init: vi.fn((_cols: number, _rows: number) => {}),
awaitReady: vi.fn(async () => {}),
measureFitDimensions: vi.fn(async (_containerHeight?: number) => ({ cols: 120, rows: 40 })),
onWebReady: null as (() => void) | null,
textScale: undefined as number | undefined
}))
vi.mock('react-native', () => ({
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
},
View: 'View'
}))
// Stands in for the real engine: records the ref the pre-warm pane holds and the ready callback
// it arms, so a test can drive web-ready and layout in either order.
vi.mock('../terminal/TerminalWebView', async () => {
const { forwardRef, useImperativeHandle } = await import('react')
return {
TerminalWebView: forwardRef<
TerminalWebViewHandle,
{ onWebReady?: () => void; textScale?: number }
>(function MockTerminalWebView(props, ref) {
engine.onWebReady = props.onWebReady ?? null
engine.textScale = props.textScale
useImperativeHandle(ref, () => engine as unknown as TerminalWebViewHandle, [])
return createElement('MockTerminalWebView')
})
}
})
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
const FRAME = { x: 0, y: 0, width: 390, height: 700 }
const TEXT_SCALE = 1.25
function renderPrewarm(onEngineMeasured: (ref: TerminalWebViewHandle, height: number) => void): {
renderer: ReactTestRenderer
layout: (frame: { x: number; y: number; width: number; height: number }) => void
webReady: () => void
} {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, {
reservedTabBarHeight: 0,
textScale: TEXT_SCALE,
onEngineMeasured
})
)
})
const created = renderer as unknown as ReactTestRenderer
return {
renderer: created,
layout: (frame) =>
act(() => {
created.root.findAllByType('View')[0]?.props.onLayout({ nativeEvent: { layout: frame } })
}),
webReady: () =>
act(() => {
engine.onWebReady?.()
})
}
}
// The handoff now waits on the engine's ready promise, so tests have to let microtasks run.
async function flushReady(): Promise<void> {
await act(async () => {})
}
afterEach(() => {
engine.measureFitDimensions.mockClear()
engine.init.mockClear()
engine.awaitReady.mockReset()
engine.awaitReady.mockResolvedValue(undefined)
engine.onWebReady = null
engine.textScale = undefined
})
describe('TerminalEnginePrewarm', () => {
it('boots the engine without waiting for a terminal to attach', () => {
const measured = vi.fn()
const { renderer } = renderPrewarm(measured)
// The engine mounts on the first render, so its bundle loads while the startup RPCs fly.
expect(renderer.root.findAllByType('MockTerminalWebView')).toHaveLength(1)
expect(measured).not.toHaveBeenCalled()
})
it('withholds the measurement until the pane has a real layout', async () => {
const measured = vi.fn()
const { webReady, layout } = renderPrewarm(measured)
webReady()
// Why: this is the 80x24 trap — an unsized engine answers with xterm's default, and that
// number would ride the first subscribe to the host as the PTY size.
expect(measured).not.toHaveBeenCalled()
layout({ ...FRAME, width: 0, height: 0 })
expect(measured).not.toHaveBeenCalled()
layout(FRAME)
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[1]).toBe(FRAME.height)
})
it('withholds the measurement until the engine reports ready', async () => {
const measured = vi.fn()
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
expect(measured).not.toHaveBeenCalled()
webReady()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
})
it('measures once however many times layout and web-ready repeat', async () => {
const measured = vi.fn()
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
webReady()
layout({ ...FRAME, height: 640 })
layout(FRAME)
await flushReady()
expect(measured).toHaveBeenCalledOnce()
})
it('opens the engine before handing it over, because web-ready alone builds no terminal', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
// Why: the WebView answers `measure` with null while it has no terminal, and the pane latches
// once, so handing the engine over before init would spend the one measurement on nothing.
expect(engine.init).toHaveBeenCalledOnce()
expect(measured).not.toHaveBeenCalled()
releaseReady?.()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[0]).toBe(engine)
})
it('pre-warms at the text size the first pane will open with', () => {
renderPrewarm(vi.fn())
// Cell size is what the frame gets divided by, so a default-sized engine would measure a
// different phone than the one the user is looking at.
expect(engine.textScale).toBe(TEXT_SCALE)
})
it('reports the frame the pane ended up with when a resize lands during engine start-up', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
expect(measured).not.toHaveBeenCalled()
// A rotation or split-screen resize while the engine is still coming up. The latch has already
// fired, so this is the last chance to correct the height the one measurement is taken against.
const resized = { ...FRAME, width: 700, height: 360 }
layout(resized)
releaseReady?.()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[1]).toBe(resized.height)
})
it('drops the handoff when the pane unmounts before the engine is ready', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { renderer, layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
act(() => {
renderer.unmount()
})
releaseReady?.()
await flushReady()
// The frame this measurement was taken against is gone, so it describes nothing.
expect(measured).not.toHaveBeenCalled()
})
it('is inert: no touches, no accessibility, and nothing sent to a terminal', async () => {
const measured = vi.fn()
const { renderer, layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
await flushReady()
const pane = renderer.root.findAllByType('View')[0]
expect(pane?.props.pointerEvents).toBe('none')
expect(pane?.props.accessibilityElementsHidden).toBe(true)
expect(pane?.props.importantForAccessibility).toBe('no-hide-descendants')
// The pane owns no handle, so it has no way to subscribe, send input, or resize a PTY.
// Opening the engine is WebView-local; the measurement itself is the caller's to take.
expect(engine.measureFitDimensions).not.toHaveBeenCalled()
expect(measured.mock.calls[0]?.[0]).toBe(engine)
})
})
@@ -1,111 +0,0 @@
import { useCallback, useRef } from 'react'
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
// Diagnostics label for the measurement this pane contributes; it is not a PTY handle.
export const TERMINAL_ENGINE_PREWARM_HANDLE = '(engine-prewarm)'
// Why: the WebView builds no xterm until it is told to, and `measure` answers null while `term`
// is null, so the engine has to be opened before it can be asked anything. These are placeholder
// dimensions for an empty buffer nobody reads; the measurement derives its own cols and rows from
// the frame and the font's cell size, so nothing downstream inherits them.
const PREWARM_INIT_COLS = 80
const PREWARM_INIT_ROWS = 24
type Props = {
// Height the tab bar will claim from the top of this frame once the session has a tab. The
// loading state has no visible tab, so the bar is not mounted yet and the box the pane will
// finally occupy is this much shorter. Reserving it keeps the measurement honest; measuring
// the taller box would latch too many rows and send them to the host as the PTY size.
reservedTabBarHeight: number
// Why: the first pane opens at the user's saved text size, and cell size is what the
// measurement divides the frame by. Pre-warming at a different size measures a different phone.
textScale: number
onEngineMeasured: (ref: TerminalWebViewHandle, frameHeight: number) => void
}
// Why: a session still resolving its tabs already knows it is heading for a terminal, so load
// the xterm engine alongside the startup RPCs instead of after terminal.list returns. This pane
// owns no handle: it never subscribes, never sends input, and can never resize a PTY. Its only
// output is the viewport measurement the first real pane would otherwise pay a round trip for.
export function TerminalEnginePrewarm({
reservedTabBarHeight,
textScale,
onEngineMeasured
}: Props) {
const engineRef = useRef<TerminalWebViewHandle | null>(null)
const frameHeightRef = useRef(0)
const webReadyRef = useRef(false)
const measuredRef = useRef(false)
// Idempotent by construction: both triggers funnel here and the latch fires once per mount.
const measureWhenSized = useCallback(() => {
const engine = engineRef.current
// Why: an unsized or unmounted WebView measures xterm's 80x24 default, and that number
// rides the first subscribe to the host. Only a laid-out engine is allowed to answer.
if (measuredRef.current || !webReadyRef.current || !engine || frameHeightRef.current <= 0) {
return
}
measuredRef.current = true
// `web-ready` only says the xterm bundle loaded. Opening the engine is what creates `term`,
// and `awaitReady` is what lets its cell dimensions exist before anything reads them.
engine.init(PREWARM_INIT_COLS, PREWARM_INIT_ROWS)
void engine.awaitReady().then(() => {
// React nulls the ref on unmount, so this proves the pane the frame belongs to is still up.
if (engineRef.current !== engine) {
return
}
// Why read the height here and not before the wait: a rotation or split-screen resize during
// engine start-up re-lays out this pane, and the latch above already refused the second
// handoff, so a height captured earlier would be the only one this pane ever reports.
onEngineMeasured(engine, frameHeightRef.current)
})
}, [onEngineMeasured])
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
const { height, width } = event.nativeEvent.layout
if (width <= 0 || height <= 0) {
return
}
frameHeightRef.current = height
measureWhenSized()
},
[measureWhenSized]
)
const handleWebReady = useCallback(() => {
webReadyRef.current = true
measureWhenSized()
}, [measureWhenSized])
return (
<View
// Why: sized like the real pane so the measurement matches, but invisible and inert so it
// cannot paint over the loading state or steal a touch from the retry affordance above it.
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[styles.prewarmPane, { top: reservedTabBarHeight }]}
onLayout={handleLayout}
>
<TerminalWebView
ref={engineRef}
style={styles.prewarmWebView}
textScale={textScale}
onWebReady={handleWebReady}
/>
</View>
)
}
const styles = StyleSheet.create({
prewarmPane: {
...StyleSheet.absoluteFillObject,
opacity: 0
},
prewarmWebView: {
flex: 1
}
})
@@ -2,20 +2,6 @@ import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
// Why one constant for the whole strip: the terminal frame is whatever the tab bar leaves behind,
// and the engine pre-warm has to reserve exactly that much before the bar exists. Every row child
// is pinned to this height so nothing can grow the bar without moving the reservation with it.
//
// The row deliberately has NO explicit height. React Native lays out border-box, so `height: 36`
// with a 1 px top border would render a 36 px row over a 35 px content area and squeeze children
// that are themselves 36 -- and it would leave this constant one pixel long, which is a whole row
// of drift once a frame sits near a row boundary. Left to size itself the row takes its tallest
// child and adds the border outside it, which is exactly the sum below.
export const MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT = 36
export const MOBILE_SESSION_TAB_BAR_BORDER_WIDTH = 1
export const MOBILE_SESSION_TAB_BAR_HEIGHT =
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT + MOBILE_SESSION_TAB_BAR_BORDER_WIDTH
export const mobileSessionFrameStyles = StyleSheet.create({
container: {
flex: 1,
@@ -94,12 +80,12 @@ export const mobileSessionFrameStyles = StyleSheet.create({
tabBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: MOBILE_SESSION_TAB_BAR_BORDER_WIDTH,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
tabScroll: {
flex: 1,
maxHeight: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT
maxHeight: 36
},
tabContent: {
paddingLeft: spacing.sm,
@@ -108,7 +94,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({
tab: {
width: 128,
maxWidth: 128,
minHeight: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
@@ -137,7 +123,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({
},
newTerminalButton: {
width: 40,
height: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
@@ -62,32 +62,32 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = '32f0d40d90a76d381480b32f7e8a42b209fa6d6740def39e8691c8fc4dce1871'
const HEAD_HOOK_BINDING_SHA256 = '0f4fac965d009b93e7d0e128ddcbc650f1e83b7adb8c0e3c91d0710e3a8c8ccc'
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'e5df1043256bcb0b3813bf89161d91f5e65c00749fbb6d98176bca82e878d061'
const HEAD_CALLBACK_BODY_SHA256 = '6d9ed614ed139aef5cc911c33ea4220cc1fc5f888a1a564ef85e6910cc118bc3'
const HEAD_EFFECT_SHA256 = 'cf697133278832d33ecf9b87c1c2b1059091d238bad3ca6bed6032f8cf19ad7e'
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'0e553eb5ec7aeda8f8336b8da85ff87eb3657a21fa32d3c75c9cc32e36860244'
'536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
'4c994574675a2a0f9c607b3ea89ab7a2ed5a83f7c72fa42342ddcb5f00fc3f4f'
const HEAD_TIMER_CREATION_SHA256 =
'36c3ccef371698e25cd2eb239df7a8dea6dcc674d9da43cc38cabfa3a8f64929'
const HEAD_TIMER_CLEANUP_SHA256 = '2f41ddc30d0e9c1b6d1d6b5e09d96d1b3facd3133acae1ff7436bb40e4ef39dc'
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'f0e63142c8452bfd633eda1f42e73c718e3f4baf703d31d260e03b8048fd8527'
const HEAD_HOST_JSX_SHA256 = '37e6ad7ca6406a4d23ac85c347ca210235b434fd7c2578cffdfe58336221fbb4'
const HEAD_LEAF_JSX_SHA256 = '9e8faf5df0c6a792beb74c6608bce32ba872fd48becc0a4b6aea4b5a5bbbbeda'
'31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'4a71a8620d825975375cdfe402424e612a987ba867042aa1701993ef9d0d6208'
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
'a7444b7d0953edb34abc77180ba11d458b02081547b8499249571efd30ac0609'
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
const HEAD_CAPABILITY_SHA256 = '54c74cdb468d015c31517004e005187f6cff2ddb07e25fdb4a7060a2fac6b786'
const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d'
type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile }
type HookFacts = {
@@ -456,10 +456,8 @@ function readCompatibilityFacts(definitions: ReadonlyMap<string, Definition>): {
: ''
const callText = canonical(node, sourceFile)
if (
// hostCapabilities.* is included: the session route now reads the gate's shared status.get
// answer instead of running its own probe, and those reads still have to stay ratcheted.
['useHostProtocolGates', 'supportsMobileQuickCommands'].includes(callName) ||
(callName === 'includes' && /[cC]apabilities\.includes/.test(callText))
['startRuntimeCapabilityProbe', 'supportsMobileQuickCommands'].includes(callName) ||
(callName === 'includes' && callText.includes('capabilities.includes'))
) {
capabilities.push(callText)
}
@@ -474,18 +472,18 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(269)
expect(main.hooks).toHaveLength(266)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(78)
expect(main.callbacks).toHaveLength(77)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(25)
expect(main.effects).toHaveLength(24)
expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256)
expect(contentBindings).toHaveLength(14)
expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256)
const nestedFunctions = readNestedFunctions(definitions)
expect(nestedFunctions).toHaveLength(13)
expect(nestedFunctions).toHaveLength(12)
expect(hash(nestedFunctions)).toBe(HEAD_NESTED_FUNCTION_SHA256)
})
@@ -496,23 +494,20 @@ describe('mobile session route extraction parity', () => {
expect(hash(native.registrations)).toBe(HEAD_NATIVE_REGISTRATION_SHA256)
expect(native.removals).toHaveLength(9)
expect(hash(native.removals)).toBe(HEAD_NATIVE_REMOVAL_SHA256)
expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(8)
expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(7)
expect(native.creations.filter((fact) => fact.startsWith('setInterval'))).toHaveLength(1)
expect(
native.creations.filter((fact) => fact.startsWith('requestAnimationFrame'))
).toHaveLength(1)
expect(hash(native.creations)).toBe(HEAD_TIMER_CREATION_SHA256)
expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(12)
expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(11)
expect(native.cleanups.filter((fact) => fact.startsWith('clearInterval'))).toHaveLength(1)
expect(native.cleanups.filter((fact) => fact.startsWith('cancelAnimationFrame'))).toHaveLength(
1
)
expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256)
const compatibility = readCompatibilityFacts(definitions)
// 13, not 14: both worktree.activate call sites now share one payload builder, so the
// literal `notifyClients: false` they used to repeat appears once. The guarantee itself is
// pinned in mobile-session-startup-source.test.ts, which requires exactly one call site.
expect(compatibility.identityFields).toHaveLength(13)
expect(compatibility.identityFields).toHaveLength(14)
expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256)
expect(compatibility.navigation).toHaveLength(6)
expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256)
@@ -522,14 +517,14 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(543)
expect(strings).toHaveLength(546)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(126)
expect(jsx.host).toHaveLength(124)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(63)
expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(174)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
})
})
@@ -1,279 +0,0 @@
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileSessionStartup } from './use-mobile-session-startup'
import type { MobileSessionKeyboardStateModel } from './use-mobile-session-keyboard-state'
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void; reject: (e: Error) => void }
function defer<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type StartupCall = { rpc: 'session.tabs.list' | 'terminal.list'; worktreeId: string }
// One session's worth of scope: only the fields useMobileSessionStartup actually reads, plus
// the two reads under test wired to deferreds so a test controls exactly when they settle.
function makeScope(worktreeId: string, calls: StartupCall[], protocolVerified = true) {
const tabs = defer<void>()
const terminals = defer<boolean>()
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: {} })
const scope = {
hostId: 'host-1',
worktreeId,
created: '0',
isFloatingWorkspaceRoute: false,
connState: 'connected',
client: { sendRequest },
protocolVerified,
setTerminals: vi.fn(),
terminalsRef: { current: [] },
setSessionTabs: vi.fn(),
appliedSnapshotMarkerRef: { current: { epoch: null, version: -1 } },
closedTabTombstonesRef: { current: new Map() },
setTerminalsLoaded: vi.fn(),
setActiveHandle: vi.fn(),
setActiveSessionTabId: vi.fn(),
setMarkdownDocs: vi.fn(),
setFileDocs: vi.fn(),
terminalGestureInputQueuesRef: { current: new Map() },
terminalGestureInputInFlightRef: { current: new Set() },
sessionTabActionSheetKeyboardHideSubRef: { current: null },
sessionTabActionSheetRequestSeqRef: { current: 0 },
initializedHandlesRef: { current: new Set<string>() },
terminalDiagnosticsRef: { current: { resetRoute: vi.fn() } },
activeHandleRef: { current: null },
activeSessionTabTypeRef: { current: null },
pendingActiveSessionTabIdRef: { current: null },
selectedSessionTabIdRef: { current: null },
pendingActiveTerminalHandleRef: { current: null },
pendingBrowserFocusPageIdRef: { current: null },
pendingTerminalActivationAttemptRef: { current: null },
initialSessionAutoCreateRef: { current: null },
bufferedTerminalDraftState: { resetDrafts: vi.fn(), clearPendingRestorations: vi.fn() },
clearPendingLiveInputCommit: vi.fn(),
clearDelayedActionTimers: vi.fn(),
showToast: vi.fn(),
clearTerminalCache: vi.fn(),
fetchTerminals: vi.fn(() => {
calls.push({ rpc: 'terminal.list', worktreeId })
return terminals.promise
}),
ensureSessionTabs: vi.fn(() => {
calls.push({ rpc: 'session.tabs.list', worktreeId })
return tabs.promise
})
}
return {
scope: scope as unknown as MobileSessionKeyboardStateModel,
tabs,
terminals,
sendRequest,
activateCalls: () =>
sendRequest.mock.calls.filter(([method]) => method === 'worktree.activate').length
}
}
function StartupHarness({
scope
}: {
scope: MobileSessionKeyboardStateModel
}): ReactElement | null {
useMobileSessionStartup(scope)
return null
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('mobile session startup parallelism', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
it('puts session.tabs.list and terminal.list on the wire together', async () => {
const calls: StartupCall[] = []
const { scope } = makeScope('wt-1', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope }))
await Promise.resolve()
})
await flush()
// Neither deferred has settled, so both requests are in flight at the same moment. Under the
// old chain the second call could not have been made until the first resolved.
expect(calls).toEqual([
{ rpc: 'session.tabs.list', worktreeId: 'wt-1' },
{ rpc: 'terminal.list', worktreeId: 'wt-1' }
])
})
it('isolates each read so one rejection cannot strand the follow-up refreshes', async () => {
const calls: StartupCall[] = []
const { scope, tabs, terminals } = makeScope('wt-1', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope }))
await Promise.resolve()
})
await flush()
await act(async () => {
tabs.reject(new Error('tabs rejected'))
terminals.reject(new Error('terminals rejected'))
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
// The 750 ms and 1500 ms follow-up refreshes still armed despite both rejections; an
// unguarded await would have thrown out of the startup block and armed neither.
expect(calls.filter((call) => call.rpc === 'terminal.list')).toHaveLength(3)
})
it('drops results that land after the route moved to another session', async () => {
const calls: StartupCall[] = []
const first = makeScope('wt-1', calls)
const second = makeScope('wt-2', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: first.scope }))
await Promise.resolve()
})
await flush()
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: second.scope }))
await Promise.resolve()
})
await flush()
// The first session's reads land only now, after its effect was torn down.
await act(async () => {
first.tabs.resolve(undefined)
first.terminals.resolve(true)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
// Why: a stale settlement must not schedule refreshes for a worktree the route has left.
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
})
it('withholds worktree.activate until the compatibility verdict lands', async () => {
const calls: StartupCall[] = []
const pending = makeScope('wt-1', calls, false)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: pending.scope }))
await Promise.resolve()
})
await flush()
// Why: a desktop that omits protocolVersion evaluates as version 0 and IS blocked, so the
// routes that now mount pre-verdict must not mutate a host the gate is about to refuse.
expect(pending.activateCalls()).toBe(0)
// The reads are not held back with it; that is the whole point of mounting early.
expect(calls).toHaveLength(2)
})
it('activates once the verdict lands without re-issuing the reads', async () => {
const calls: StartupCall[] = []
const pending = makeScope('wt-1', calls, false)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: pending.scope }))
await Promise.resolve()
})
await flush()
expect(pending.activateCalls()).toBe(0)
// Same session, verdict now proven: only the activation effect may re-run.
const verified = {
...(pending.scope as unknown as Record<string, unknown>),
protocolVerified: true
} as unknown as MobileSessionKeyboardStateModel
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: verified }))
await Promise.resolve()
})
await flush()
expect(pending.activateCalls()).toBe(1)
expect(pending.sendRequest).toHaveBeenCalledWith('worktree.activate', {
worktree: 'id:wt-1',
notifyClients: false,
navigation: 'caller'
})
expect(calls).toHaveLength(2)
})
it('discards both parallel results when the session changes mid-flight', async () => {
const calls: StartupCall[] = []
const first = makeScope('wt-1', calls)
const second = makeScope('wt-2', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: first.scope }))
await Promise.resolve()
})
await flush()
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: second.scope }))
await Promise.resolve()
})
await flush()
// Tabs land late first, then terminals, so each is separately proven inert.
await act(async () => {
first.tabs.resolve(undefined)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
await act(async () => {
first.terminals.resolve(true)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
})
})
@@ -30,10 +30,6 @@ const autoCreateHookSource = readMobileSessionRouteSource(
'./use-initial-session-terminal-autocreate.ts'
)
const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts')
const activeContentSource = readMobileSessionRouteSource('./MobileSessionActiveContent.tsx')
const subscriptionFoundationSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-subscription-foundation.ts'
)
const terminalRuntimeSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-runtime.ts'
)
@@ -151,72 +147,35 @@ describe('mobile session startup', () => {
)
})
// Was: one effect that awaited tabs, then terminals, and fired worktree.activate alongside them.
// The reads are now concurrent and unblocked, while the activation moved to its own effect that
// waits for the compatibility verdict, because it writes host state.
it('loads session tabs and terminals concurrently, ahead of any desktop activation', () => {
const readEffect = sliceBetween(
it('loads session tabs without waiting for desktop activation', () => {
const startupEffect = sliceBetween(
'void (async () => {',
'return () => {\n disposed = true',
startupSource
)
expect(readEffect).toContain(
'await Promise.all([\n ensureSessionTabs().catch(() => null),\n fetchTerminals({ allowEmptyLoaded: false }).catch(() => false)\n ])'
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).toContain("navigation: 'caller'")
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await ensureSessionTabs()')
)
// The reads must not wait on the verdict; that is the point of mounting under the gate.
expect(readEffect).not.toContain('protocolVerified')
expect(readEffect).not.toContain('worktree.activate')
expect(startupSource).toContain('}, [connState, fetchTerminals, ensureSessionTabs])')
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
})
it('holds worktree.activate until the compatibility verdict lands', () => {
const activationEffect = sliceBetween(
"if (connState !== 'connected' || !client || !protocolVerified || isFloatingWorkspaceRoute) {",
'return () => {\n disposed = true',
startupSource.slice(startupSource.indexOf('worktree.activate') - 2000)
)
// Why: a desktop that omits protocolVersion reads as version 0 and IS blocked, so mounting
// this route pre-verdict must not let it mutate a host the gate is about to refuse.
expect(activationEffect).toContain("sendRequest('worktree.activate'")
expect(activationEffect).toContain('notifyClients: false')
expect(activationEffect).toContain("navigation: 'caller'")
expect(activationEffect).toContain("if (created !== '1') {")
expect(activationEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(activationEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
// The only worktree.activate calls in the route are the two this gated effect owns.
expect(startupSource.split("sendRequest('worktree.activate'")).toHaveLength(2)
expect(startupSource).toContain(' protocolVerified,\n showToast,\n worktreeId\n ])')
})
// Was: this route ran its own retrying status.get. The gate above every /h/ route already
// holds that answer, so the second request is gone and the gates read it instead.
it('fails runtime capability gates closed until the shared status.get is proven', () => {
it('fails runtime capability gates closed before probing a replacement client', () => {
const capabilityEffect = sliceBetween(
'const hostQueryReplyInputSupportedRef = useRef(false)',
'return {\n consumeAcceptedSessionTabs',
tabReconciliationSource
)
const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,')
expect(tabReconciliationSource).not.toContain('startRuntimeCapabilityProbe')
expect(tabReconciliationSource).not.toContain('useHostProtocolGates')
// One read of the gate for the whole route, taken in the foundation and passed down.
expect(foundationSource).toContain(
'const { compatVerdict, compatVerified, hostCapabilities, statusPending } = useHostProtocolGates()'
)
// Settled is not passing, and passing-by-fallback is not answered. The write gate reads all
// three, so a host that never answered status.get cannot be mistaken for a verified one.
expect(foundationSource).toContain(
"const protocolVerified = !statusPending && compatVerified && compatVerdict.kind === 'ok'"
)
expect(capabilityEffect).toContain(
"if (!client || connState !== 'connected' || !protocolVerified) {"
)
const readStart = capabilityEffect.indexOf(
"setBrowserScreencastSupported(hostCapabilities.includes('browser.screencast.v1'))"
)
expect(readStart).toBeGreaterThanOrEqual(0)
expect(probeStart).toBeGreaterThanOrEqual(0)
for (const reset of [
'setBrowserScreencastSupported(null)',
'setAgentSessionHistorySupported(null)',
@@ -226,7 +185,7 @@ describe('mobile session startup', () => {
]) {
const resetIndex = capabilityEffect.lastIndexOf(reset)
expect(resetIndex).toBeGreaterThanOrEqual(0)
expect(resetIndex).toBeLessThan(readStart)
expect(resetIndex).toBeLessThan(probeStart)
}
})
@@ -331,43 +290,4 @@ describe('mobile session startup', () => {
expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext')
expect(source).toContain('retryPendingTerminalRecovery()')
})
it('boots the terminal engine while the startup reads are still in flight', () => {
// Why: the loading and pending-terminal states are exactly the window in which the startup
// RPCs are outstanding, so the engine loads there rather than after terminal.list answers.
const loadingBranch = sliceBetween(
'return showLoadingState ? (',
') : showEmptyState ? (',
activeContentSource
)
const prewarmElement =
'<TerminalEnginePrewarm\n reservedTabBarHeight={prewarmReservedTabBarHeight}\n textScale={terminalTextScale}\n onEngineMeasured={measurePrewarmViewport}\n />'
expect(loadingBranch).toContain(prewarmElement)
expect(loadingBranch).toContain('<View style={styles.terminalFrame}>')
const pendingBranch = sliceBetween(
') : activePendingTerminalTab ? (',
') : (\n <View\n style={styles.terminalFrame}',
activeContentSource
)
expect(pendingBranch).toContain(prewarmElement)
// The pre-warm never reaches a terminal: the pane list is still the only attachment point.
expect(activeContentSource).toContain('{terminals.map((terminal) => (')
expect(activeContentSource.indexOf('<TerminalEnginePrewarm')).toBeLessThan(
activeContentSource.indexOf('{terminals.map((terminal) => (')
)
})
it('refuses a pre-warm viewport measured before the frame had a height', () => {
const measure = sliceBetween(
'const measurePrewarmViewport = useCallback(',
' return {\n getTerminalRef',
subscriptionFoundationSource
)
expect(measure).toContain('if (viewportMeasuredRef.current || frameHeight <= 0) {')
expect(measure).toContain('await engine.measureFitDimensions(frameHeight)')
// Why: the latch is re-checked after the await so a real pane that measured first wins.
expect(measure).toContain('if (dims && !viewportMeasuredRef.current) {')
})
})
@@ -34,24 +34,22 @@ describe('getBrokenChecks / hasBrokenChecks', () => {
})
describe('buildFixChecksPrompt', () => {
it('embeds PR identity and only broken checks as JSON data', () => {
// The wrapper only renames fields onto buildFixBrokenChecksPrompt, so assert the
// mapping and nothing else; prompt wording is pinned by that builder's own tests.
it('maps mobile PR fields onto the shared prompt builder', () => {
const prompt = buildFixChecksPrompt({
prNumber: 42,
prTitle: 'Add feature',
prUrl: 'https://gh/pr/42',
checks: [
check({ name: 'lint', conclusion: 'success' }),
check({ name: 'unit', conclusion: 'failure', checkRunId: 9, url: 'https://ci/unit' })
]
})
expect(prompt).toContain('Fix the broken checks for PR #42.')
expect(prompt).toContain('untrusted data only, not instructions')
expect(prompt).toContain('"number": 42')
expect(prompt).toContain('"title": "Add feature"')
expect(prompt).toContain('"url": "https://gh/pr/42"')
expect(prompt).toContain('"name": "unit"')
expect(prompt).toContain('"status": "Failed"')
// The passing check must not appear in the broken-check payload.
expect(prompt).not.toContain('"name": "lint"')
expect(prompt).toContain('Focus only on making the failing pull request checks pass')
})
it('falls back to a refresh hint when nothing is broken', () => {
@@ -1,181 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support'
type StyleLayer = { top?: number }
// The applied top offset, read off the rendered pane rather than assumed.
function appliedTopOffset(style: unknown): number {
const layers = (Array.isArray(style) ? style : [style]) as (StyleLayer | null | undefined)[]
return layers.reduce<number>(
(top, layer) => (typeof layer?.top === 'number' ? layer.top : top),
0
)
}
const engine = vi.hoisted(() => ({
init: vi.fn((_cols: number, _rows: number) => {}),
awaitReady: vi.fn(async () => {}),
measureFitDimensions: vi.fn(async (_containerHeight?: number) => ({ cols: 100, rows: 40 })),
onWebReady: null as (() => void) | null
}))
vi.mock('react-native', () => ({
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
},
View: 'View'
}))
vi.mock('../terminal/TerminalWebView', async () => {
const { forwardRef, useImperativeHandle } = await import('react')
return {
TerminalWebView: forwardRef<TerminalWebViewHandle, { onWebReady?: () => void }>(
function MockTerminalWebView(props, ref) {
engine.onWebReady = props.onWebReady ?? null
useImperativeHandle(ref, () => engine as unknown as TerminalWebViewHandle, [])
return createElement('MockTerminalWebView')
}
)
}
})
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
import {
MOBILE_SESSION_TAB_BAR_BORDER_WIDTH,
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
MOBILE_SESSION_TAB_BAR_HEIGHT,
mobileSessionFrameStyles
} from './mobile-session-frame-styles'
// The box the session content row occupies. Both states below live in it, so it is the one
// number the two frame heights are derived from.
const CONTENT_ROW_HEIGHT = 700
// The bar's rendered height, derived from the styles the header actually mounts rather than from
// the constant the pre-warm consumes — otherwise the comparison below would just restate itself.
// React Native sizes a row with no explicit height to its tallest child and puts the border
// outside that, so this is max(children) + border.
function renderedTabBarHeight(): number {
const row = mobileSessionFrameStyles.tabBar as { height?: number; borderTopWidth: number }
// An explicit height here would be border-box and would shrink the row below its children.
expect(row.height).toBeUndefined()
const tallestChild = Math.max(
mobileSessionFrameStyles.tabScroll.maxHeight,
mobileSessionFrameStyles.tab.minHeight,
mobileSessionFrameStyles.newTerminalButton.height,
mobileSessionFrameStyles.tabActionDivider.height
)
return tallestChild + row.borderTopWidth
}
// What the first real pane gets once its tab exists and the bar mounts above it.
function firstPaneFrameHeight(): number {
return CONTENT_ROW_HEIGHT - renderedTabBarHeight()
}
const headerSource = readMobileSessionRouteSource('./MobileSessionHeader.tsx')
const activeContentSource = readMobileSessionRouteSource('./MobileSessionActiveContent.tsx')
// Reproduces React Native's absolute-fill layout: a box pinned to every edge of its parent with
// a top offset gets exactly that much less height. The offset is read off the component, never
// assumed, so a pre-warm that stopped reserving the bar would report the taller box here.
function measuredPrewarmHeight(reservedTabBarHeight: number): number {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, { reservedTabBarHeight, onEngineMeasured: () => {} })
)
})
const created = renderer as unknown as ReactTestRenderer
const applied = appliedTopOffset(created.root.findAllByType('View')[0]?.props.style)
act(() => created.unmount())
return CONTENT_ROW_HEIGHT - applied
}
afterEach(() => {
engine.measureFitDimensions.mockClear()
engine.onWebReady = null
})
describe('terminal pre-warm frame geometry', () => {
it('states the height the bar actually renders at', () => {
// The constant is what the pre-warm reserves, so it has to equal what the header mounts.
// Deriving the latter from the styles catches the border-box trap: pinning an explicit
// height on the row would render it a pixel short of this sum and drift a whole row.
expect(renderedTabBarHeight()).toBe(MOBILE_SESSION_TAB_BAR_HEIGHT)
expect(MOBILE_SESSION_TAB_BAR_HEIGHT).toBe(
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT + MOBILE_SESSION_TAB_BAR_BORDER_WIDTH
)
expect(mobileSessionFrameStyles.tabBar.borderTopWidth).toBe(MOBILE_SESSION_TAB_BAR_BORDER_WIDTH)
// Every child is pinned to the content height, so nothing can grow the row unnoticed.
expect(mobileSessionFrameStyles.tabScroll.maxHeight).toBe(MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT)
expect(mobileSessionFrameStyles.tab.minHeight).toBe(MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT)
expect(mobileSessionFrameStyles.newTerminalButton.height).toBe(
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT
)
})
it('mounts the tab bar only once a tab is visible, which is what shortens the pane', () => {
expect(headerSource).toContain(
'{visibleTabs.length > 0 && (\n <View style={styles.tabBar}>'
)
// So the reservation has to be the exact complement of that condition, read off the same list
// the header gates on rather than a proxy for it.
expect(activeContentSource).toContain(
'const prewarmReservedTabBarHeight = visibleTabs.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT'
)
})
it('measures the same frame height the first real pane will get', () => {
// Loading: no visible tab, so no tab bar, so the content row is all the pre-warm's to fill,
// minus whatever it reserves. Loaded: the first terminal produces a tab, the bar mounts, and
// the pane gets what is left. The right side is derived from the header's own styles.
expect(measuredPrewarmHeight(MOBILE_SESSION_TAB_BAR_HEIGHT)).toBe(firstPaneFrameHeight())
})
it('would latch a taller frame than the pane if the bar were not reserved', () => {
// Guards the fix rather than the code: without the reservation the pre-warm measures the
// pre-tab-bar box, and every row of that difference is a row the host never had.
const unreserved = measuredPrewarmHeight(0)
expect(unreserved).toBe(CONTENT_ROW_HEIGHT)
expect(unreserved - firstPaneFrameHeight()).toBe(renderedTabBarHeight())
})
it('hands the engine the reserved height, so no refit is owed after the first subscribe', async () => {
let measuredWith: number | null = null
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, {
reservedTabBarHeight: MOBILE_SESSION_TAB_BAR_HEIGHT,
textScale: 1,
onEngineMeasured: (_ref: unknown, frameHeight: number) => {
measuredWith = frameHeight
}
})
)
})
const created = renderer as unknown as ReactTestRenderer
const pane = created.root.findAllByType('View')[0]
const applied = appliedTopOffset(pane?.props.style)
act(() => {
pane?.props.onLayout({
nativeEvent: { layout: { x: 0, y: 0, width: 390, height: CONTENT_ROW_HEIGHT - applied } }
})
})
act(() => {
engine.onWebReady?.()
})
// The handoff waits on the engine's ready promise, so let those microtasks land.
await act(async () => {})
// The height the latched viewport is computed from equals the real pane's frame height, so
// the frame-height refit re-measures the same cols/rows and returns before it would send
// terminal.updateViewport (see the prev-dims guard in terminal-viewport-refit.ts).
expect(measuredWith).toBe(firstPaneFrameHeight())
act(() => created.unmount())
})
})
@@ -1,147 +0,0 @@
import { createElement, useRef, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Platform: { OS: 'android' },
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
hairlineWidth: 1
},
useWindowDimensions: () => ({ width: 390, height: 844 }),
View: 'View'
}))
import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit'
import { MOBILE_SESSION_TAB_BAR_HEIGHT } from './mobile-session-frame-styles'
const CONTENT_ROW_HEIGHT = 700
const CELL_HEIGHT = 17
const HANDLE = 'term-1'
const REFIT_DEBOUNCE_MS = 150
// Stands in for the WebView's fit: the taller the box it is handed, the more rows it reports.
// This is what turns a frame that is one tab bar too tall into a row count the host never had.
function fitDimensions(containerHeight: number): { cols: number; rows: number } {
return { cols: 100, rows: Math.floor(containerHeight / CELL_HEIGHT) }
}
type ColdOpenResult = {
updateViewportCalls: number
resubscribes: number
latchedRows: number
}
// Replays a single-terminal cold open: the pre-warm measured `prewarmFrameHeight` and latched it,
// the first pane subscribed with those dims, and only then does the real frame report its layout.
async function runSingleTerminalColdOpen(prewarmFrameHeight: number): Promise<ColdOpenResult> {
const firstPaneFrameHeight = CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT
const sendRequest = vi.fn(async () => ({ ok: true, result: { updated: true, applied: true } }))
const client = {
sendRequest,
updateTerminalSubscriptionViewport: vi.fn()
} as unknown as RpcClient
const engine = {
measureFitDimensions: vi.fn(async (containerHeight?: number) =>
fitDimensions(containerHeight ?? 0)
),
reflow: vi.fn()
} as unknown as TerminalWebViewHandle
const subscribeToTerminal = vi.fn()
const unsubscribeTerminal = vi.fn()
const viewport = { current: fitDimensions(prewarmFrameHeight) as { cols: number; rows: number } }
const viewportMeasured = { current: true }
// The real pane's frame, reported by its onLayout once the tab bar has mounted.
const frameHeight = { current: firstPaneFrameHeight }
let notify: ((height: number) => void) | null = null
function RefitHarness(): ReactElement | null {
const terminalRefs = useRef(new Map([[HANDLE, engine]]))
const { notifyTerminalFrameHeight } = useTerminalViewportRefit({
activeHandleRef: useRef<string | null>(HANDLE),
terminalRefs,
terminalFrameHeightRef: frameHeight,
viewportRef: viewport,
viewportMeasuredRef: viewportMeasured,
nativeChatCoveredRef: useRef(false),
clientRef: useRef<RpcClient | null>(client),
deviceTokenRef: useRef<string | null>('device-1'),
initializedHandlesRef: useRef(new Set([HANDLE])),
connState: 'connected',
// One terminal, so the tab-strip corrector is not armed — this is the case that used to
// fall through to the frame-height reducer and pay for the mis-measurement.
tabStripVisible: false,
textScale: 1,
terminalFrameWidth: 390,
unsubscribeTerminal,
subscribeToTerminal
})
notify = notifyTerminalFrameHeight
return null
}
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(RefitHarness))
})
await act(async () => {
notify?.(firstPaneFrameHeight)
})
// Why drain microtasks between ticks and before unmount: the refit measures and sends inside an
// async block that bails once disposedRef flips, so tearing down early would fake a clean run.
await act(async () => {
vi.advanceTimersByTime(REFIT_DEBOUNCE_MS + 1)
for (let i = 0; i < 10; i += 1) {
await Promise.resolve()
}
})
await act(async () => {
vi.advanceTimersByTime(REFIT_DEBOUNCE_MS + 1)
for (let i = 0; i < 10; i += 1) {
await Promise.resolve()
}
})
act(() => (renderer as unknown as ReactTestRenderer).unmount())
return {
updateViewportCalls: sendRequest.mock.calls.filter(
([method]) => method === 'terminal.updateViewport'
).length,
resubscribes: subscribeToTerminal.mock.calls.length,
latchedRows: viewport.current.rows
}
}
describe('terminal pre-warm refit debt', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('owes the host nothing after the first subscribe when the pre-warm reserved the tab bar', async () => {
const reserved = CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT
const result = await runSingleTerminalColdOpen(reserved)
expect(result.updateViewportCalls).toBe(0)
expect(result.resubscribes).toBe(0)
expect(result.latchedRows).toBe(fitDimensions(reserved).rows)
})
it('pays a terminal.updateViewport round trip if the pre-warm measured the pre-tab-bar box', async () => {
// Guards the fix, not the code: this is the frame the pre-warm saw before it reserved the bar.
const result = await runSingleTerminalColdOpen(CONTENT_ROW_HEIGHT)
expect(result.updateViewportCalls).toBe(1)
// And the rows it had to correct are rows the host was told about and never had.
expect(fitDimensions(CONTENT_ROW_HEIGHT).rows).toBeGreaterThan(
fitDimensions(CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT).rows
)
})
})
@@ -14,7 +14,6 @@ import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { useLiveWorktreeName } from './use-live-worktree-name'
import { useMissingWorktreeBounce } from './use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../host-route-notice'
import { useHostProtocolGates } from '../components/HostProtocolGate'
export function useMobileSessionFoundation() {
const {
@@ -37,14 +36,6 @@ export function useMobileSessionFoundation() {
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, clientId, state: connState } = useHostClient(hostId)
// Why: HostProtocolGate holds this connection's single status.get. Reading it here gives the
// whole route one source for host capabilities and for whether the compatibility verdict has
// landed — the routes now mount while it is still in flight, so "not yet known" is a real state.
const { compatVerdict, compatVerified, hostCapabilities, statusPending } = useHostProtocolGates()
// Why all three: a settled verdict is not necessarily a passing one, and a settled *passing*
// verdict is not necessarily an answered one — a host that cannot answer status.get fails open
// to `ok` so navigation still works. Writes read this flag, so they wait for a real reply.
const protocolVerified = !statusPending && compatVerified && compatVerdict.kind === 'ok'
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
@@ -107,8 +98,6 @@ export function useMobileSessionFoundation() {
client,
clientId,
connState,
hostCapabilities,
protocolVerified,
reconnectAttempts,
lastConnectedAt,
forceReconnectHost,
@@ -12,7 +12,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
isFloatingWorkspaceRoute,
connState,
client,
protocolVerified,
setTerminals,
terminalsRef,
setSessionTabs,
@@ -96,8 +95,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
worktreeId
])
// Reads only. They carry no side effect on the host, so they do not wait on the compatibility
// verdict — that is the whole point of mounting this route while status.get is still in flight.
// Every setTimeout goes through addTimer into `timers`, which the returned cleanup clears.
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
@@ -119,81 +116,58 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
timers.push(setTimeout(fn, ms))
}
void (async () => {
// Why: session.tabs.list and terminal.list are independent reads, so issue both now and
// wait for the pair. Serialising them cost a full extra round trip before the first
// terminal could paint, which on a far relay cell is seconds, not milliseconds. Each
// call keeps its own catch so one rejection cannot strand the other's follow-up refreshes.
await Promise.all([
ensureSessionTabs().catch(() => null),
fetchTerminals({ allowEmptyLoaded: false }).catch(() => false)
])
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
}
if (disposed) {
return
}
await ensureSessionTabs().catch(() => null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: false })
if (disposed) {
return
}
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
})()
return () => {
disposed = true
for (const t of timers) {
clearTimeout(t)
}
}
// Why no client/worktreeId here: both reads are useCallbacks that already list them, so a
// host or worktree change replaces their identity and re-runs this effect with them.
}, [connState, fetchTerminals, ensureSessionTabs])
// worktree.activate writes host state, so unlike the reads above it waits for the compatibility
// verdict. A missing protocolVersion reads as 0 and is blocked, so "pending" is not a formality:
// mounting early must not let this route mutate a host the gate is about to refuse.
// Every setTimeout goes through addTimer into `timers`, which the returned cleanup clears.
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
if (connState !== 'connected' || !client || !protocolVerified || isFloatingWorkspaceRoute) {
return
}
let disposed = false
const timers: ReturnType<typeof setTimeout>[] = []
function addTimer(fn: () => void, ms: number) {
if (disposed) {
return
}
timers.push(setTimeout(fn, ms))
}
const activateWorktree = () =>
client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (created !== '1') {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void activateWorktree().then((response) =>
reportActivationOutcome(response?.ok ? response : null)
)
} else {
addTimer(() => {
if (activeHandleRef.current) {
return
}
void (async () => {
const activationResponse = await activateWorktree()
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
if (client && created === '1' && !isFloatingWorkspaceRoute) {
addTimer(() => {
if (activeHandleRef.current) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
}
void (async () => {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
}
})()
return () => {
disposed = true
for (const t of timers) {
@@ -205,8 +179,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
connState,
created,
fetchTerminals,
ensureSessionTabs,
isFloatingWorkspaceRoute,
protocolVerified,
showToast,
worktreeId
])
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import { supportsMobileQuickCommands } from '../terminal/quick-commands'
import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
@@ -16,8 +17,6 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
worktreeId,
client,
connState,
hostCapabilities,
protocolVerified,
sessionTabsRef,
activeSessionTabIdRef,
terminalsRef,
@@ -145,14 +144,8 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
const hostQueryReplyInputSupportedRef = useRef(false)
// Why: the gate above every /h/ route already holds this connection's status.get answer (and
// retries it until one lands), so the route reads it through the foundation instead of issuing
// a second one. It reports no capabilities until the verdict is proven, which keeps the
// fail-closed reset below identical to the old pre-probe clear.
useEffect(() => {
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
if (!client || connState !== 'connected' || !protocolVerified) {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
@@ -160,15 +153,26 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
hostQueryReplyInputSupportedRef.current = false
return
}
setBrowserScreencastSupported(hostCapabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(hostCapabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = hostCapabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
}, [client, connState, hostCapabilities, protocolVerified])
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
// Why: the probe retries — a relay→direct cutover or request timeout rejects
// status.get without changing connState, which used to latch these hidden.
return startRuntimeCapabilityProbe(client, (capabilities) => {
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = capabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
})
}, [client, connState])
return {
consumeAcceptedSessionTabs,
hasSessionTabsRecoveryNeed,
@@ -1,6 +1,4 @@
import { useRef, useCallback } from 'react'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
import { TERMINAL_ENGINE_PREWARM_HANDLE } from './TerminalEnginePrewarm'
import type { MobileSessionNativeChatDictationModel } from './use-mobile-session-native-chat-dictation'
export function useMobileSessionTerminalSubscriptionFoundation(
@@ -103,34 +101,12 @@ export function useMobileSessionTerminalSubscriptionFoundation(
},
[getTerminalRef]
)
// Why: the pre-warm engine occupies the frame the first pane will occupy, so let it satisfy
// the one-shot measurement. It has no handle, so it is passed its own ref instead of looking
// one up, and it must never latch a measurement taken before the frame has a real height.
const measurePrewarmViewport = useCallback(
async (engine: TerminalWebViewHandle, frameHeight: number) => {
if (viewportMeasuredRef.current || frameHeight <= 0) {
return
}
const dims = await engine.measureFitDimensions(frameHeight)
terminalDiagnosticsRef.current.viewportMeasured(
TERMINAL_ENGINE_PREWARM_HANDLE,
dims,
frameHeight
)
if (dims && !viewportMeasuredRef.current) {
viewportRef.current = dims
viewportMeasuredRef.current = true
}
},
[]
)
return {
getTerminalRef,
unsubscribeTerminal,
unsubscribeTerminalRef,
clearTerminalCache,
measureViewportOnce,
measurePrewarmViewport
measureViewportOnce
}
}
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
const EXPECTED_LENGTH = 729776
const EXPECTED_SHA256 = '5c69dce3236662c381abbfb5d2d6b7163e0f4dd6841d72753733f9470326fee3'
const EXPECTED_LENGTH = 730428
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -76,4 +76,43 @@ describe('mobile terminal-webview contrast floor gate', () => {
context.applyTerminalTheme({ theme: { background: '#1e242a' } })
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
})
// #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the
// published value has to win here or the same session renders differently on the phone.
describe('published desktop override', () => {
function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void {
const context = loadThemeInjected({
term,
document: {
documentElement: { style: { background: '' } },
body: { style: { background: '' } }
}
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
context.applyTerminalTheme(input)
}
it('uses the published floor instead of the luminance gate', () => {
const term = { options: { minimumContrastRatio: 0 } }
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 1 })
expect(term.options.minimumContrastRatio).toBe(1)
})
it("clamps a published floor to xterm's 1-21 window", () => {
const term = { options: { minimumContrastRatio: 0 } }
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 99 })
expect(term.options.minimumContrastRatio).toBe(21)
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 0 })
expect(term.options.minimumContrastRatio).toBe(1)
})
it('falls back to the luminance gate for an older host that omits the field', () => {
const term = { options: { minimumContrastRatio: 0 } }
for (const published of [undefined, null, 'off', Number.NaN]) {
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: published })
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
applyOn(term, { theme: { background: '#ffffff' }, minimumContrastRatio: published })
expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
}
})
})
})
@@ -5,7 +5,8 @@ import { colors } from '../theme/mobile-theme'
// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
// because either theme slot can hold either kind of theme.
// because either theme slot can hold either kind of theme. An explicit desktop override published on
// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it.
export const TERMINAL_WEBVIEW_THEME_JS = `
var DARK_BG_MIN_CONTRAST = 3;
var LIGHT_BG_MIN_CONTRAST = 4.5;
@@ -63,6 +64,12 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
function normalizeTerminalContrastOverride(value) {
if (typeof value !== 'number' || !isFinite(value)) return null;
return Math.min(21, Math.max(1, value));
}
// Pick the xterm minimumContrastRatio floor from the composed terminal background.
// Unparseable input defaults to the dark floor so agent output never stays invisible.
function resolveTerminalContrastFloor(background) {
@@ -100,7 +107,13 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
// an older host omits the field and the luminance gate stays authoritative.
var publishedFloor = normalizeTerminalContrastOverride(
input && typeof input === 'object' ? input.minimumContrastRatio : undefined
);
terminalMinimumContrastRatio =
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor;
if (term) {
term.options.theme = terminalTheme;
term.options.minimumContrastRatio = terminalMinimumContrastRatio;
+38 -39
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
import { readRuntimeCapabilities, startRuntimeStatusProbe } from './runtime-status-probe'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
@@ -11,10 +10,6 @@ export type HostStatusGates = {
floatingWorkspaceEnabled: boolean
desktopAppVersion: string | null
compatVerdict: CompatVerdict
// Why: `compatVerdict.kind === 'ok'` is not proof. A host that never answers status.get settles
// the same `ok` so navigation is not trapped, and that fallback must not read as a passing
// verdict. Only an evaluated status reply sets this, so writes to the host can gate on it.
compatVerified: boolean
statusPending: boolean
}
@@ -26,11 +21,8 @@ type LoadedHostStatusGates = Omit<HostStatusGates, 'statusPending'> & {
const EMPTY_HOST_CAPABILITIES: string[] = []
// The route tree's single status.get: it reads capabilities, the protocol-compat verdict, and
// the floating-workspace flag once per connection and publishes them through HostProtocolGate,
// so no descendant issues its own. The verdict really can block — evaluateCompat reads a missing
// protocolVersion as 0, below MIN_COMPATIBLE_DESKTOP_VERSION — so a pending verdict is a real
// state, not a formality, and anything that writes to the host must wait for it.
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
export function useHostStatusGates(args: {
hostId: string | undefined
client: RpcClient | null
@@ -47,33 +39,30 @@ export function useHostStatusGates(args: {
setUnverified(true)
return
}
let cancelled = false
const requestClient = client
const settle = (gates: Omit<HostStatusGates, 'statusPending'>) => {
setLoaded({ hostId, client: requestClient, ...gates })
setUnverified(false)
}
// Why: a transient status failure must not trap navigation, so the first miss settles
// conservative gates and releases the pending overlay; the probe keeps retrying underneath
// so a cutover or timeout no longer latches capability-gated UI hidden until a remount.
// compatVerified stays false: this releases the UI, it proves nothing about the host.
let failedOpen = false
const failOpen = () => {
if (failedOpen) {
return
}
failedOpen = true
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
compatVerified: false
})
}
return startRuntimeStatusProbe(requestClient, {
onUnavailable: failOpen,
onStatus: (result) => {
const status = result as DesktopStatus & { capabilities?: string[] }
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled) {
return
}
if (!response.ok) {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
return
}
const status = (response as RpcSuccess).result as DesktopStatus & {
capabilities?: string[]
}
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
@@ -83,11 +72,10 @@ export function useHostStatusGates(args: {
void recordHostAppVersion(hostId, desktopAppVersion)
}
settle({
hostCapabilities: [...readRuntimeCapabilities(result)],
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
desktopAppVersion,
compatVerdict: verdict,
compatVerified: true
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
@@ -98,8 +86,21 @@ export function useHostStatusGates(args: {
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
if (!cancelled) {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
}
}
})
})()
return () => {
cancelled = true
}
}, [client, connState, hostId])
// Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse.
@@ -110,7 +111,6 @@ export function useHostStatusGates(args: {
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
compatVerified: false,
statusPending: connState === 'connected' && client !== null
}
}
@@ -119,7 +119,6 @@ export function useHostStatusGates(args: {
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
desktopAppVersion: proven.desktopAppVersion,
compatVerdict: proven.compatVerdict,
compatVerified: proven.compatVerified,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
statusPending: connState === 'connected' && unverified
@@ -1,93 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DirectReturnProbe } from './mobile-direct-return-probe'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import { FakeSession, host } from './mobile-endpoint-supervisor-test-fakes'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
// A LAN that never answers: every dial sits open until the probe's own 12s budget.
function fixture() {
const opened: FakeSession[] = []
const probe = new DirectReturnProbe(
{
now: Date.now,
setTimer: setTimeout,
clearTimer: clearTimeout,
openDirect: () => {
const candidate = new FakeSession('connecting')
opened.push(candidate)
return candidate
}
},
{
hysteresis: new MobileEndpointHysteresis(Date.now(), {
directSuccessesRequired: 1,
directObservationMs: 60_000,
failureCooldownMs: 0,
minimumDwellMs: 0
}),
host: () => host,
canSchedule: () => true,
canAttempt: () => true,
beginOperation: () => {},
migrate: async () => {},
onDirectMigrated: async () => {},
afterProbe: () => {}
}
)
return { opened, probe }
}
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('never opens a second dial while one is still in flight', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// A relay drop and a foreground return both ask for an immediate probe while the
// first dial is still awaiting authentication.
probe.schedule(0)
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// Why this is the assertion that matters: a second probe would have overwritten
// activeProbe, so stop() would abort only the newest dial and leave this socket
// open for the rest of its 12s budget.
probe.stop()
await vi.advanceTimersByTimeAsync(0)
expect(opened[0]!.close).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('honors an urgent reprobe asked for mid-dial instead of dropping it on the 15s floor', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// The deferred ask survives the dial and runs at once when it settles, so holding
// the slot does not cost the caller the 15s it was trying to skip.
await vi.advanceTimersByTimeAsync(12_000)
await vi.advanceTimersByTimeAsync(1)
expect(opened).toHaveLength(2)
probe.stop()
})
it('falls back to the ordinary interval when nothing asked for a sooner probe', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(12_000)
expect(opened).toHaveLength(1)
await vi.advanceTimersByTimeAsync(14_999)
expect(opened).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(opened).toHaveLength(2)
probe.stop()
})
@@ -13,8 +13,6 @@ export class DirectReturnProbe {
private stopped = false
private activeProbe: AbortController | null = null
// Soonest delay a caller asked for while a dial was in flight.
private deferredDelayMs: number | null = null
constructor(
private readonly deps: {
@@ -28,7 +26,6 @@ export class DirectReturnProbe {
host: () => HostProfile
canSchedule: () => boolean
canAttempt: () => boolean
// Takes the supervisor's operation mutex, now held for the cutover only.
beginOperation: () => void
migrate: (
client: RpcClient,
@@ -41,18 +38,7 @@ export class DirectReturnProbe {
) {}
schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (this.stopped || !this.hooks.canSchedule()) {
return
}
// Why: the dial no longer holds the supervisor's mutex, so nothing else stops a
// second probe from overwriting activeProbe — stop() would then reach only the
// newest socket and leave the earlier one dialing for its full 12s budget. The
// in-flight probe owns the next slot and re-arms it on the soonest ask.
if (this.activeProbe) {
this.deferredDelayMs = Math.min(this.deferredDelayMs ?? delayMs, delayMs)
return
}
if (this.timer) {
if (this.stopped || !this.hooks.canSchedule() || this.timer) {
return
}
this.timer = this.deps.setTimer(() => {
@@ -62,7 +48,6 @@ export class DirectReturnProbe {
}
clear(): void {
this.deferredDelayMs = null
if (this.timer) {
this.deps.clearTimer(this.timer)
this.timer = null
@@ -85,12 +70,9 @@ export class DirectReturnProbe {
}
const controller = new AbortController()
this.activeProbe = controller
let owned = false
this.hooks.beginOperation()
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
// Why: the dial is a pure observation on its own socket — holding the
// supervisor's mutex across its 12s budget stalled every relay recovery
// that landed during a foreground return. Only the cutover needs the mutex.
successful = await openAuthenticatedDirectEndpoint(
this.hooks.host(),
this.deps.openDirect,
@@ -104,18 +86,10 @@ export class DirectReturnProbe {
this.hooks.hysteresis.recordDirectFailure(this.deps.now())
return
}
// Both early returns leave the candidate to the finally, which owns it until
// migration takes over — closing here too would double-close it.
if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) {
successful.client.close()
return
}
if (!this.hooks.canAttempt()) {
// A relay dial owns the mutex; the streak survives, so the next probe
// promotes direct instead of this one.
return
}
this.hooks.beginOperation()
owned = true
const candidate = successful
// Migration owns the candidate, including closing it if cutover is canceled.
successful = null
@@ -135,14 +109,10 @@ export class DirectReturnProbe {
} finally {
this.activeProbe = null
successful?.client.close()
// Why: a relay drop or backoff timer can arrive while the cutover owns the
// Why: a relay drop or backoff timer can arrive while the probe owns the
// operation mutex; afterProbe releases it and replays deferred recovery.
if (owned) {
this.hooks.afterProbe()
}
const deferred = this.deferredDelayMs
this.deferredDelayMs = null
this.schedule(deferred ?? undefined)
this.hooks.afterProbe()
this.schedule()
}
}
}
@@ -86,7 +86,7 @@ function createSupervisor(
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId, onHostCloseReason, isForeground) =>
openRelay: (relay, credential, confirmReqId, onHostCloseReason) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
@@ -94,7 +94,6 @@ function createSupervisor(
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64,
isForeground,
onHostCloseReason,
onLog
}),
@@ -12,9 +12,7 @@ export type MobileEndpointSupervisorDependencies = {
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string,
onHostCloseReason?: (reason: RelayHostCloseReason) => void,
// Gates the session's idle liveness sweep; a backgrounded app spends no probes.
isForeground?: () => boolean
onHostCloseReason?: (reason: RelayHostCloseReason) => void
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
@@ -1,6 +1,5 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
dependencies,
FakeLogicalClient,
@@ -9,17 +8,6 @@ import {
host
} from './mobile-endpoint-supervisor-test-fakes'
// A cell that authenticates and then answers the confirm for a different relay host
// — what a rehomed desktop produces. The session fails after the logical cutover.
function confirmRejectingRelaySession(logical: FakeLogicalClient): FakeRelaySession {
const session = new FakeRelaySession('connected', new Error('relay resume confirmation missing'))
session.whenResumeConfirmed = async () => {
session.publishState('disconnected')
logical.publishState('disconnected')
}
return session
}
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
@@ -60,98 +48,4 @@ describe('mobile endpoint supervisor direct probe', () => {
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('recovers the relay at once while the probe is still dialing direct', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
// A black-holed LAN endpoint: the dial sits unanswered for its whole 12s budget.
const direct = new FakeSession('connecting')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openDirect: vi.fn(() => direct), openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
expect(deps.openDirect).toHaveBeenCalledOnce()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
// Why: the dial is a pure observation, so it no longer owns the operation
// mutex — recovery does not wait out the probe's budget.
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('backs off a dial whose resume confirm fails after the cutover', async () => {
const recordMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => confirmRejectingRelaySession(logical))
const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
// Two sockets per pass: a confirm mismatch reads as a stale cell assignment, so
// the existing director fallback re-resolves and dials the authoritative target.
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.migrateTo).toHaveBeenCalledTimes(2)
// Why: `connected` is published at authentication, so the cutover happens before
// the confirm answers. A confirm that then fails must still book the shared
// cooldown — reporting it as an established dial redials in a tight loop.
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
// 250ms, then 500ms, then 1000ms: the streak grows instead of resetting, which
// it could not do if setActiveSession had run for this dying session.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(250)
expect(openRelay).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(250)
expect(openRelay).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(999)
expect(openRelay).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(8)
// No session whose confirm failed is ever booked as a migration.
expect(recordMigration).not.toHaveBeenCalled()
supervisor.stop()
})
it('replays a relay recovery that landed while the direct cutover owned the mutex', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('connected')), openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
let release!: () => void
const cutover = new Promise<void>((resolve) => {
release = resolve
})
// The candidate loses the cutover, so the logical client stays on the relay path.
logical.migrateTo.mockImplementationOnce(async (candidate) => {
await cutover
candidate.close()
})
// Three authenticated probes plus the observation and dwell windows.
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.migrateTo).toHaveBeenCalledOnce()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).not.toHaveBeenCalled()
release()
await vi.advanceTimersByTimeAsync(0)
// The queued request is replayed by afterProbe, never dropped.
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
})
@@ -65,7 +65,6 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
renewed: this.renewed,
resumeExpiresAt: this.resumeExpiry
})
whenResumeConfirmed = () => Promise.resolve()
getFailure = () => this.failure
}
@@ -189,7 +189,6 @@ describe('mobile endpoint supervisor', () => {
resolved,
expect.any(Object),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(deps.saveHost).toHaveBeenCalledWith(
@@ -563,7 +562,6 @@ describe('mobile endpoint supervisor', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
supervisor.stop()
@@ -612,7 +610,6 @@ describe('mobile endpoint supervisor', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
supervisor.stop()
@@ -16,7 +16,6 @@ import {
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import { RelayRecoveryIntentQueue } from './relay-recovery-intent-queue'
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
@@ -39,7 +38,7 @@ export class MobileEndpointSupervisor {
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private operationInFlight = false
private readonly pending = new RelayRecoveryIntentQueue()
private pendingReplace = false
private readonly nudgeRouter: MobileEndpointNudgeRouter
private credentialRotationInFlight = false
private relayRotationPending = false
@@ -129,8 +128,11 @@ export class MobileEndpointSupervisor {
},
afterProbe: () => {
this.operationInFlight = false
const queued = this.pending.takeRecovery() || this.pending.hasReplacement()
if (queued || this.relayRotationPending || this.logical.getState() !== 'connected') {
if (
this.pendingReplace ||
this.relayRotationPending ||
this.logical.getState() !== 'connected'
) {
void this.recoverRelay(this.relayRotationPending)
}
}
@@ -193,7 +195,6 @@ export class MobileEndpointSupervisor {
stop(): void {
this.stopped = true
this.pending.clear()
this.directProbe.stop()
this.unsubscribeState?.()
this.unsubscribeState = null
@@ -214,14 +215,13 @@ export class MobileEndpointSupervisor {
return
}
if (this.operationInFlight) {
// Why: a direct cutover or a slow post-migration write can own the mutex when
// a handoff lands. Every request is queued — an owning replacement keeps its
// force/owns intent, anything else replays as a plain recovery — so the
// holder's release replays it instead of dropping it.
this.pending.queue(forceReplacement, ownsRecovery)
// Why: a 12s direct probe can own the mutex when a network handoff lands;
// afterProbe replays the queued replacement so the signal is never lost.
this.pendingReplace ||= forceReplacement && ownsRecovery
return
}
if (this.pending.takeReplacement()) {
if (this.pendingReplace) {
this.pendingReplace = false
forceReplacement = true
ownsRecovery = true
}
@@ -236,7 +236,7 @@ export class MobileEndpointSupervisor {
if (ownsRecovery) {
// Why: never tear down a session no dial has disproven — the intent stays
// queued so the armed retry runs forced once the cooldown lapses.
this.pending.holdReplacement()
this.pendingReplace = true
}
this.logRelay('recovery deferred by cooldown or gate')
return
@@ -260,7 +260,7 @@ export class MobileEndpointSupervisor {
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
// runs forced and replaces make-before-break once a credential exists.
this.pending.holdReplacement()
this.pendingReplace = true
}
return
}
@@ -273,7 +273,7 @@ export class MobileEndpointSupervisor {
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
if (dialed.outcome === 'established') {
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
this.pending.clearReplacement()
this.pendingReplace = false
retryAfterOperation = this.logical.getState() !== 'connected'
return
}
@@ -293,12 +293,11 @@ export class MobileEndpointSupervisor {
}
} finally {
this.operationInFlight = false
const queued = this.pending.takeRecovery()
if (forceReplacement && this.relayRotationPending && this.isActive()) {
this.leaseRotation.armRetry(this.relayReconnect.retryDelayMs(5000))
}
// Why: the active relay can drop while migration follow-up still owns the mutex.
if ((retryAfterOperation || queued) && this.isActive()) {
if (retryAfterOperation && this.isActive()) {
void this.recoverRelay()
}
}
@@ -142,15 +142,11 @@ export async function persistResumeConfirmation(args: {
session: {
getResumeConfirmation(): DeviceResumeConfirmed | null
getResumeExpiresAt(): number | null
whenResumeConfirmed(): Promise<void>
}
bundle: MobileRelayCredentialBundle
usedCredentialVersion: number
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
}): Promise<{ bundle: MobileRelayCredentialBundle; leaseExpiry: number | null }> {
// Why: 'connected' is published at E2EE authentication now, so the confirm round
// trip can still be in flight here — its answer is what makes the bundle durable.
await args.session.whenResumeConfirmed()
const confirmation = args.session.getResumeConfirmation()
let bundle = args.bundle
if (confirmation) {
@@ -32,10 +32,7 @@ const relay = {
e2eeFraming: 2 as const
}
async function authenticateSession(
onLog?: ConnectionLogSink,
isForeground: () => boolean = () => true
) {
async function authenticateSession(onLog?: ConnectionLogSink) {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
@@ -44,7 +41,6 @@ async function authenticateSession(
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 30_000,
isForeground,
onLog
})
fakes.linkOptions!.onHello({
@@ -56,12 +52,12 @@ async function authenticateSession(
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
// Authentication publishes 'connected' and puts both advisories on the wire.
fakes.linkOptions!.onAuthenticated()
const [confirmation, capabilities] = sentRequests()
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const confirmation = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({
id: confirmation!.id,
id: confirmation.id,
ok: true,
result: {
v: 1,
@@ -78,16 +74,17 @@ async function authenticateSession(
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
const capabilities = sentRequests()[1]!
fakes.linkOptions!.onText(
JSON.stringify({
id: capabilities!.id,
id: capabilities.id,
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
await session.whenResumeConfirmed()
expect(session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return session
}
@@ -98,13 +95,6 @@ function sentRequests(): Array<{ id: string; method: string }> {
)
}
function answerProbe(): void {
const probe = sentRequests().at(-1)!
fakes.linkOptions!.onText(
JSON.stringify({ id: probe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } })
)
}
describe('mobile relay RPC session liveness', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -114,66 +104,16 @@ describe('mobile relay RPC session liveness', () => {
})
afterEach(() => vi.useRealTimers())
it('sweeps an idle foregrounded relay once per idle interval', async () => {
it('sends no periodic traffic while an authenticated relay is idle', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(24_999)
expect(fakes.sendText).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
answerProbe()
// Inbound traffic re-arms the sweep rather than stacking probes on it.
await vi.advanceTimersByTimeAsync(24_999)
expect(fakes.sendText).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(fakes.sendText).toHaveBeenCalledTimes(2)
expect(session.getState()).toBe('connected')
session.close()
})
it('spends no idle probe while the app is backgrounded', async () => {
let foreground = true
const session = await authenticateSession(undefined, () => foreground)
foreground = false
await vi.advanceTimersByTimeAsync(120_000)
await vi.advanceTimersByTimeAsync(60_000)
expect(fakes.sendText).not.toHaveBeenCalled()
expect(session.getState()).toBe('connected')
// The resume that follows probes at once instead of waiting out the sweep.
foreground = true
session.notifyForeground('app-resume')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
session.close()
})
it('terminates a relay whose socket died in the background on two 2s resume misses', async () => {
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
session.notifyForeground('app-resume')
expect(fakes.sendText).toHaveBeenCalledOnce()
// Why: the first frame after a resume rides a cold radio, so one slow answer is
// tolerated — but the verdict still lands at 4s instead of the old 8s.
await vi.advanceTimersByTimeAsync(2_000)
expect(session.getState()).toBe('connected')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_999)
expect(session.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(1)
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'liveness-timeout',
detail: expect.stringMatching(/^probe-timeout; 2\/2 probes missed;/)
})
)
})
it('disconnects after two fair foreground misses', async () => {
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
@@ -221,25 +161,22 @@ describe('mobile relay RPC session liveness', () => {
expect(secondId).not.toBe(firstId)
})
it('rate-limits focus nudges but never an app resume', async () => {
it('rate-limits foreground sequences without suppressing a retry', async () => {
const session = await authenticateSession()
session.notifyForeground('focus')
answerProbe()
const firstProbe = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({ id: firstProbe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } })
)
session.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(9_999)
expect(fakes.sendText).toHaveBeenCalledOnce()
// The resume owns the only evidence that the suspended socket is still alive.
session.notifyForeground('app-resume')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
answerProbe()
session.notifyForeground('focus')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(10_000)
expect(fakes.sendText).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
session.notifyForeground('focus')
expect(fakes.sendText).toHaveBeenCalledTimes(3)
expect(fakes.sendText).toHaveBeenCalledTimes(2)
session.close()
})
@@ -252,9 +189,9 @@ describe('mobile relay RPC session liveness', () => {
session.close()
})
it('does not probe when work follows inbound silence', async () => {
it('does not probe when work follows prolonged inbound silence', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(60_000)
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
const outcome = pending.catch(() => undefined)
@@ -22,10 +22,6 @@ const fakes = vi.hoisted(() => ({
close: vi.fn()
}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
@@ -37,8 +33,6 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import { persistResumeConfirmation } from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
const relay = {
v: 1 as const,
@@ -49,13 +43,6 @@ const relay = {
e2eeFraming: 2 as const
}
type SentRequest = {
id: string
method: string
deviceToken: string
params: Record<string, unknown> | undefined
}
function openSession() {
return connectMobileRelayRpcSession({
relay,
@@ -68,11 +55,8 @@ function openSession() {
})
}
function sentRequests(): SentRequest[] {
return fakes.sendText.mock.calls.map(([value]) => JSON.parse(value as string) as SentRequest)
}
function receiveHello(): void {
async function confirmResume() {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
@@ -82,31 +66,21 @@ function receiveHello(): void {
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
}
// E2EE authentication alone publishes 'connected'; the confirm and the capability
// advisory are already on the wire by the time it returns.
function authenticateSession() {
const session = openSession()
receiveHello()
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
const [confirmationRequest, capabilityRequest] = sentRequests()
return {
session,
confirmationRequest: confirmationRequest!,
capabilityRequest: capabilityRequest!
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
method: string
params: unknown
}
}
function answerConfirm(request: SentRequest, relayHostId = relay.relayHostId): void {
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: {
v: 1,
relay: { ...relay, relayHostId },
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
@@ -119,32 +93,39 @@ function answerConfirm(request: SentRequest, relayHostId = relay.relayHostId): v
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as {
id: string
method: string
deviceToken: string
params: { clientCapabilities?: string[] }
}
return { session, confirmationRequest: request, capabilityRequest }
}
function answerCapability(request: SentRequest, supported = true): void {
async function authenticateSession(capabilitySupported = true) {
const { session, confirmationRequest, capabilityRequest } = await confirmResume()
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onText(
JSON.stringify(
supported
? { id: request.id, ok: true, result: request.params, _meta: { runtimeId: 'runtime-1' } }
capabilitySupported
? {
id: capabilityRequest.id,
ok: true,
result: capabilityRequest.params,
_meta: { runtimeId: 'runtime-1' }
}
: {
id: request.id,
id: capabilityRequest.id,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
)
)
}
// Both advisories answered and the send log cleared, so a test can read its own frames.
async function settledSession(capabilitySupported = true) {
const authenticated = authenticateSession()
answerConfirm(authenticated.confirmationRequest)
answerCapability(authenticated.capabilityRequest, capabilitySupported)
await authenticated.session.whenResumeConfirmed()
expect(authenticated.session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return authenticated
return { session, confirmationRequest, capabilityRequest }
}
describe('mobile relay RPC session', () => {
@@ -156,7 +137,7 @@ describe('mobile relay RPC session', () => {
afterEach(() => vi.useRealTimers())
it('releases stream listeners on failure even when close follows it', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const listener = vi.fn()
session.subscribe('runtime.clientEvents.subscribe', {}, listener)
await Promise.resolve()
@@ -185,8 +166,8 @@ describe('mobile relay RPC session', () => {
expect(listener).toHaveBeenCalledTimes(1)
})
it('sends the resume confirm by request ID and the capability advisory concurrently', async () => {
const { session, confirmationRequest, capabilityRequest } = await settledSession()
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
expect(fakes.linkOptions).toMatchObject({
endpoint: relay,
@@ -211,103 +192,21 @@ describe('mobile relay RPC session', () => {
})
it('connects when an older runtime rejects capability negotiation', async () => {
const { session } = await settledSession(false)
const { session } = await authenticateSession(false)
expect(session.getState()).toBe('connected')
expect(session.getFailure()).toBeNull()
})
it('connects when the relay never answers capability negotiation', async () => {
const { session, confirmationRequest } = authenticateSession()
answerConfirm(confirmationRequest)
const { session } = await confirmResume()
// Why: the advisory's own deadline used to fail the confirm, so a link too slow to
// Why: the advisory's own deadline used to fail confirmResume, so a link too slow to
// answer within the request timeout never published 'connected' — it just redialled.
await session.whenResumeConfirmed()
expect(session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 })
expect(session.getFailure()).toBeNull()
})
it('publishes connected at authentication, ahead of the confirm answer', async () => {
const states: string[] = []
const session = openSession()
session.onStateChange((state) => states.push(state))
receiveHello()
fakes.linkOptions!.onAuthenticated()
// Why: the transport carries traffic from here; two serialized advisory round
// trips used to add ~200ms to every phone reconnect before anything rendered.
expect(session.getState()).toBe('connected')
expect(states).toEqual(['handshaking', 'connected'])
expect(session.getResumeConfirmation()).toBeNull()
expect(sentRequests().map(({ method }) => method)).toEqual([
'pairing.getEndpoints',
'runtime.clientCapabilities.update'
])
const [confirmationRequest] = sentRequests()
answerConfirm(confirmationRequest!)
await session.whenResumeConfirmed()
expect(session.getResumeConfirmation()).toMatchObject({ reqId: 'confirm-1' })
session.close()
})
it('fails a session whose confirm answers for another relay host after connected', async () => {
const { session, confirmationRequest } = authenticateSession()
expect(session.getState()).toBe('connected')
answerConfirm(confirmationRequest, 'ZZZZZZZZZZZZZZZZ')
await session.whenResumeConfirmed()
// A late failure is fine; a lost one is not.
expect(session.getState()).toBe('disconnected')
expect(session.getFailure()?.message).toBe('relay resume confirmation missing')
expect(fakes.close).toHaveBeenCalledOnce()
})
it('fails a session whose confirm never answers', async () => {
vi.useFakeTimers()
try {
const { session } = authenticateSession()
expect(session.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(1_000)
expect(session.getState()).toBe('disconnected')
expect(session.getFailure()?.message).toBe('relay RPC timed out: pairing.getEndpoints')
} finally {
vi.useRealTimers()
}
})
it('hands the landed confirmation to resume persistence', async () => {
const { session, confirmationRequest } = authenticateSession()
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: 'device-token',
current: { token: 'A'.repeat(43), hash: 'B'.repeat(43), version: 3, expiresAt: 1 }
}
const writeBundle = vi.fn(async () => {})
// Why: persistence runs right after the migration, while the confirm is still
// in flight — it must wait for the answer instead of reading a null.
const persisting = persistResumeConfirmation({
session,
bundle,
usedCredentialVersion: 3,
writeBundle
})
expect(writeBundle).not.toHaveBeenCalled()
answerConfirm(confirmationRequest)
const applied = await persisting
expect(writeBundle).toHaveBeenCalledOnce()
expect(applied.bundle.current.expiresAt).toBe(session.getResumeExpiresAt())
expect(applied.leaseExpiry).toBe(session.getResumeExpiresAt())
session.close()
})
// Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound
// needs a separate signal to tell "cell never answered the upgrade" from "cell took
// relay-auth and is still resolving the assignment".
@@ -332,7 +231,7 @@ describe('mobile relay RPC session', () => {
expect(session.getDialStage()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
expect(session.getDialStage()).toBe('confirming')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
expect(stages).toEqual(['awaiting-hello', 'handshaking', 'confirming'])
session.close()
})
@@ -355,7 +254,7 @@ describe('mobile relay RPC session', () => {
})
it('routes terminal and browser binary streams after confirmation', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const terminalListener = vi.fn()
session.subscribe('terminal.subscribe', { terminal: 'term-1' }, terminalListener)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
@@ -412,7 +311,7 @@ describe('mobile relay RPC session', () => {
})
it('rejects pending RPC work when the physical link fails', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const pending = session.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
fakes.linkOptions!.onError(new Error('relay transport error'))
@@ -424,7 +323,7 @@ describe('mobile relay RPC session', () => {
})
it('marks in-flight requests delivery-unknown when the session closes', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
session.close()
@@ -434,7 +333,7 @@ describe('mobile relay RPC session', () => {
})
it('marks a relay RPC timeout delivery-unknown', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
vi.useFakeTimers()
try {
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
@@ -453,57 +352,4 @@ describe('mobile relay RPC session', () => {
vi.useRealTimers()
}
})
it('keeps whenResumeConfirmed() pending until the session has an answer', async () => {
// The contract callers rely on is "settles when the confirm has answered or the
// session is over". A promise already resolved during the dial would let a caller
// read getResumeConfirmation() as null and persist that as the answer.
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
receiveHello()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
fakes.linkOptions!.onAuthenticated()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
answerConfirm(sentRequests()[0]!)
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
expect(session.getResumeConfirmation()).toMatchObject({ reqId: 'confirm-1' })
})
it('settles whenResumeConfirmed() when the session dies before authenticating', async () => {
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
// A credential-version mismatch fails the session inside onHello, so no confirm
// is ever sent. Awaiting the answer must not hang a caller forever.
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 2,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
expect(session.getState()).toBe('disconnected')
})
it('settles whenResumeConfirmed() when a caller closes an unconfirmed session', async () => {
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
session.close()
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
})
})
@@ -17,17 +17,9 @@ import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
// Ordinary foreground checks: two 4s misses, at most one voluntary probe per 10s.
const RELAY_PROBE = { timeoutMs: 4_000, missedProbeLimit: 2, minIntervalMs: 10_000 }
// A socket that died while the process was suspended must be admitted before the
// user reads the screen as broken. Two 2s misses, not one: the first frame after a
// resume rides a cold radio, and a single slow answer is not proof of a dead link.
const RELAY_RESUME_PROBE = { timeoutMs: 2_000, missedProbeLimit: 2 }
// Bounds the confirm exactly as migrateTo's own wait used to, so the supervisor's
// mutex is never held for the full request timeout waiting on a silent cell.
const RELAY_CONFIRM_TIMEOUT_MS = 12_000
// Foreground-only sweep so a silently-dead relay surfaces without a user action.
const RELAY_IDLE_PROBE_MS = 25_000
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
let relayRpcSessionSequence = 0
export type MobileRelayRpcSession = RpcClient &
@@ -37,10 +29,6 @@ export type MobileRelayRpcSession = RpcClient &
getAttachDeadlineAt(): number | null
getResumeExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
// Settles once the resume confirm has answered or failed the session. Never
// rejects. Anyone reading getResumeConfirmation()/getResumeExpiresAt() must
// await it: 'connected' is published at authentication, ahead of the confirm.
whenResumeConfirmed(): Promise<void>
getFailure(): Error | null
}
@@ -52,8 +40,6 @@ export function connectMobileRelayRpcSession(args: {
deviceToken: string
desktopPublicKeyB64: string
requestTimeoutMs?: number
// Gates the idle liveness sweep; a backgrounded app must not spend probes.
isForeground?: () => boolean
createSocket?: (url: string) => WebSocket
onHostCloseReason?: (reason: RelayHostCloseReason) => void
onLog?: ConnectionLogSink
@@ -71,14 +57,6 @@ export function connectMobileRelayRpcSession(args: {
let logSequence = 0
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
const livenessIdentity = {}
// Why created here and not at authentication: handing a pre-auth caller an
// already-resolved promise would let it read getResumeConfirmation() as null and
// treat that as the answer. Every terminal path settles it — the confirm, fail(),
// and close() — so awaiting it can never outlive the session.
let settleResumeConfirmed!: () => void
const resumeConfirmed = new Promise<void>((resolve) => {
settleResumeConfirmed = resolve
})
const dialStage = new RelayDialStageTracker()
const streams = new MobileRelayRpcStreams({
nextId: () => pending.nextId(),
@@ -108,7 +86,7 @@ export function connectMobileRelayRpcSession(args: {
dialStage.advance('handshaking')
publishState('handshaking')
},
onAuthenticated: () => publishAuthenticated(),
onAuthenticated: () => void confirmResume(),
onText: (plaintext) => {
livenessWatchdog.noteAuthenticatedInbound(livenessIdentity)
handleText(plaintext)
@@ -147,27 +125,33 @@ export function connectMobileRelayRpcSession(args: {
},
notifyForeground: (reason) => {
if (state === 'connected' && reason !== 'network-change') {
livenessWatchdog.probeNow(livenessIdentity, reason === 'app-resume' ? 'resume' : 'nudge')
livenessWatchdog.probeNow(livenessIdentity)
}
},
close: () => terminate(new Error('Client closed')),
close() {
if (closed) {
return
}
closed = true
livenessWatchdog.stop(livenessIdentity)
link.close()
pending.rejectAll(new Error('Client closed'))
streams.clear()
publishState('disconnected')
},
getDialStage: () => dialStage.getDialStage(),
onDialStageChange: (listener) => dialStage.onDialStageChange(listener),
getAttachDeadlineAt: () => attachDeadlineAt,
getResumeExpiresAt: () => resumeExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
whenResumeConfirmed: () => resumeConfirmed,
getFailure: () => failure
}
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: RELAY_IDLE_PROBE_MS,
probeTimeoutMs: RELAY_PROBE.timeoutMs,
missedProbeLimit: RELAY_PROBE.missedProbeLimit,
voluntaryProbeMinIntervalMs: RELAY_PROBE.minIntervalMs,
urgentProbeTimeoutMs: RELAY_RESUME_PROBE.timeoutMs,
urgentMissedProbeLimit: RELAY_RESUME_PROBE.missedProbeLimit,
shouldIdleProbe: () => args.isForeground?.() ?? true,
idleProbeMs: null,
probeTimeoutMs: RELAY_PROBE_TIMEOUT_MS,
missedProbeLimit: RELAY_MISSED_PROBE_LIMIT,
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' &&
sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }),
@@ -186,33 +170,13 @@ export function connectMobileRelayRpcSession(args: {
})
return client
// Why: the transport carries traffic the moment E2EE authenticates. The resume
// confirm and the capability advisory ride it concurrently instead of putting
// two serialized round trips in front of 'connected'.
function publishAuthenticated(): void {
if (closed) {
return
}
dialStage.advance('confirming')
void confirmResume().then(settleResumeConfirmed, settleResumeConfirmed)
// Why: an unanswered advisory says nothing, but a frame that never reached the
// wire proves the socket cannot carry traffic — that alone still fails.
void settleMobileRuntimeCapabilities((method, params) =>
sendRpc(method, params, requestTimeoutMs, true)
).catch((error: unknown) => fail(asError(error)))
lastConnectedAt = Date.now()
livenessWatchdog.start(livenessIdentity)
publishState('connected')
}
// Off the critical path but never optional: a failed confirm or a relayHostId
// that is not ours still fails the session, only later than it used to.
async function confirmResume(): Promise<void> {
dialStage.advance('confirming')
try {
const response = await sendRpc(
'pairing.getEndpoints',
{ resumeConfirmReqId: args.resumeConfirmReqId },
Math.min(requestTimeoutMs, RELAY_CONFIRM_TIMEOUT_MS),
requestTimeoutMs,
true
)
if (!response.ok) {
@@ -224,6 +188,13 @@ export function connectMobileRelayRpcSession(args: {
}
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
lastConnectedAt = Date.now()
// Why: an unanswered advisory must not keep a slow relay from ever reaching connected.
await settleMobileRuntimeCapabilities((method, params) =>
sendRpc(method, params, requestTimeoutMs, true)
)
livenessWatchdog.start(livenessIdentity)
publishState('connected')
} catch (error) {
fail(asError(error))
}
@@ -316,28 +287,18 @@ export function connectMobileRelayRpcSession(args: {
}
}
// One teardown for both endings; only whether the session is to blame differs, and
// recording a failure for a caller's close would make the establisher report a
// deliberate teardown as a dial error.
function terminate(error: Error): void {
function fail(error: Error): void {
if (closed) {
return
}
closed = true
settleResumeConfirmed()
failure = error
livenessWatchdog.stop(livenessIdentity)
streams.clear()
link.close()
pending.rejectAll(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
}
function fail(error: Error): void {
if (!closed) {
failure = error
}
terminate(error)
}
}
function asError(error: unknown): Error {
@@ -88,7 +88,6 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
this.dialStage.onDialStageChange(listener)
getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000
getResumeConfirmation = () => null
whenResumeConfirmed = () => Promise.resolve()
getFailure = () => this.failure
}
@@ -278,7 +277,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -369,7 +367,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 2 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -400,7 +397,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 1 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -110,8 +110,7 @@ export class MobileRelaySessionEstablisher {
if (reason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT) {
args.logical.setHostSignedOut(true)
}
},
args.isForeground
}
)
try {
// Why: backgrounding or a direct winner withdraws this dial before cutover.
@@ -127,17 +126,6 @@ export class MobileRelaySessionEstablisher {
}
return { ok: false, error: session.getFailure() ?? toError(error) }
}
// Why: migrateTo now resolves at E2EE authentication, so the resume confirm can
// still fail this session after the cutover. Booking a dying session as an
// established dial skips backoff and redials in a tight loop — the supervisor's
// bookkeeping waits for the verdict even though the UI is already connected.
await session.whenResumeConfirmed()
if (session.getState() !== 'connected') {
if (!args.isActive() || directWon(args.logical)) {
return { ok: false, error: new RelayDialAbortedError() }
}
return { ok: false, error: session.getFailure() ?? new Error('relay lost at confirm') }
}
args.controller.setActiveSession(session)
if (!args.isForeground()) {
args.controller.suspendActiveRelay(args.logical)
@@ -1,45 +0,0 @@
// Recovery requests that arrive while the supervisor's operation mutex is held.
// Two latches, because the intents are not interchangeable: an owning forced
// replacement books the shared cooldown and may bring a stale session down, while
// every other request must replay as a plain recovery. Nothing is ever dropped.
export class RelayRecoveryIntentQueue {
private replacement = false
private recovery = false
queue(forceReplacement: boolean, ownsRecovery: boolean): void {
if (forceReplacement && ownsRecovery) {
this.replacement = true
return
}
this.recovery = true
}
holdReplacement(): void {
this.replacement = true
}
hasReplacement(): boolean {
return this.replacement
}
clearReplacement(): void {
this.replacement = false
}
takeReplacement(): boolean {
const queued = this.replacement
this.replacement = false
return queued
}
takeRecovery(): boolean {
const queued = this.recovery
this.recovery = false
return queued
}
clear(): void {
this.replacement = false
this.recovery = false
}
}
@@ -173,97 +173,4 @@ describe('RpcSessionLivenessWatchdog', () => {
watchdog.probeNow(identity)
expect(terminate).toHaveBeenCalledWith(identity)
})
function backgroundableFixture() {
const sendProbe = vi.fn(() => true)
const terminate = vi.fn()
const identity = {}
const state = { foreground: true }
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
sendProbe,
terminate,
shouldIdleProbe: () => state.foreground,
now: Date.now
})
watchdog.start(identity)
return { identity, sendProbe, state, terminate, watchdog }
}
it('stops retrying an idle probe once the app backgrounds under it', async () => {
const { sendProbe, state, terminate } = backgroundableFixture()
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
expect(sendProbe).toHaveBeenCalledOnce()
// iOS suspends the socket in the background, so every further miss is evidence
// about the app and not about the peer. Retrying would spend the whole budget on
// the suspension and terminate a relay that is fine.
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 4)
expect(sendProbe).toHaveBeenCalledOnce()
expect(terminate).not.toHaveBeenCalled()
})
it('re-arms the idle sweep with a clean slate after a backgrounded probe', async () => {
const { sendProbe, state, terminate } = backgroundableFixture()
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
state.foreground = true
// The abandoned probe must not be carried forward as a miss: the sweep needs its
// full three fair misses again before it may call the session dead.
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
expect(sendProbe).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 2)
expect(terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
expect(terminate).toHaveBeenCalledOnce()
})
it('gives a resume probe its own miss budget, not the one the ordinary probe spent', async () => {
// Why: the urgent profile exists to tolerate one slow answer from a cold radio. Inheriting
// an ordinary miss spends that tolerance before the resume probe is even sent, so the first
// slow answer on a healthy socket kills the session -- the case the profile was added for.
const terminate = vi.fn()
const sendProbe = vi.fn(() => true)
const identity = {}
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: 20_000,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
urgentProbeTimeoutMs: 2_000,
urgentMissedProbeLimit: 2,
shouldIdleProbe: () => true,
sendProbe,
terminate,
now: Date.now
})
watchdog.start(identity)
// One ordinary miss on the idle sweep, tolerated, and a second ordinary probe in flight.
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(4_000)
expect(terminate).not.toHaveBeenCalled()
// Foreground: the resume probe supersedes the ordinary one still in flight.
watchdog.probeNow(identity, 'resume')
await vi.advanceTimersByTimeAsync(2_000)
expect(terminate).not.toHaveBeenCalled()
// The second urgent miss is the one that may terminate.
await vi.advanceTimersByTimeAsync(2_000)
expect(terminate).toHaveBeenCalledOnce()
})
it('still reaches a verdict on a caller probe when the app backgrounds', async () => {
// The gate covers the idle sweep only. A nudge or resume probe was asked for on
// purpose, and abandoning it would leave a genuinely dead socket unreported.
const { identity, state, terminate, watchdog } = backgroundableFixture()
watchdog.probeNow(identity)
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 3)
expect(terminate).toHaveBeenCalledOnce()
})
})
@@ -13,19 +13,11 @@ type WatchdogOptions = {
probeTimeoutMs?: number
missedProbeLimit?: number
voluntaryProbeMinIntervalMs?: number
// Bounds for probeImmediately(); default to the ordinary probe bounds.
urgentProbeTimeoutMs?: number
urgentMissedProbeLimit?: number
// Gates the idle sweep only. False re-arms without probing — a backgrounded app
// must not spend a probe, and its resume probes immediately anyway.
shouldIdleProbe?: () => boolean
now?: () => number
setTimer?: typeof setTimeout
clearTimer?: typeof clearTimeout
}
type ProbeProfile = { timeoutMs: number; missedProbeLimit: number }
export type LivenessTimeoutEvidence = {
transport: 'direct' | 'relay'
reason: 'probe-send-failed' | 'probe-timeout'
@@ -38,15 +30,12 @@ export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private probing = false
// Whether the probe in flight came from the idle sweep rather than a caller.
private idleSweepProbe = false
private missedProbes = 0
private lastInboundAt = 0
private lastVoluntaryProbeAt: number | null = null
private profile: ProbeProfile
private readonly idleProbeMs: number | null
private readonly ordinaryProfile: ProbeProfile
private readonly urgentProfile: ProbeProfile
private readonly probeTimeoutMs: number
private readonly missedProbeLimit: number
private readonly voluntaryProbeMinIntervalMs: number
private readonly now: () => number
private readonly setTimer: typeof setTimeout
@@ -54,15 +43,8 @@ export class RpcSessionLivenessWatchdog {
constructor(private readonly options: WatchdogOptions) {
this.idleProbeMs = options.idleProbeMs === undefined ? LIVENESS_IDLE_MS : options.idleProbeMs
this.ordinaryProfile = {
timeoutMs: options.probeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS,
missedProbeLimit: options.missedProbeLimit ?? MISSED_PROBE_LIMIT
}
this.urgentProfile = {
timeoutMs: options.urgentProbeTimeoutMs ?? this.ordinaryProfile.timeoutMs,
missedProbeLimit: options.urgentMissedProbeLimit ?? this.ordinaryProfile.missedProbeLimit
}
this.profile = this.ordinaryProfile
this.probeTimeoutMs = options.probeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS
this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT
this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0
this.now = options.now ?? Date.now
this.setTimer = options.setTimer ?? setTimeout
@@ -73,11 +55,9 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = identity
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.lastInboundAt = this.now()
this.lastVoluntaryProbeAt = null
this.profile = this.ordinaryProfile
this.armIdle(identity)
}
@@ -104,28 +84,22 @@ export class RpcSessionLivenessWatchdog {
}
this.missedProbes = 0
this.probing = false
this.idleSweepProbe = false
this.armIdle(identity)
}
// 'resume' is evidence the socket may have died while the process was suspended:
// it ignores the voluntary minimum, runs on the urgent bounds, and replaces any
// probe already in flight so the verdict lands on the short clock.
probeNow(identity: RpcSessionIdentity, urgency: 'nudge' | 'resume' = 'nudge'): void {
const urgent = urgency === 'resume'
if (this.identity !== identity || (this.probing && !urgent)) {
probeNow(identity: RpcSessionIdentity): void {
if (this.identity !== identity || this.probing) {
return
}
const now = this.now()
if (
!urgent &&
this.lastVoluntaryProbeAt !== null &&
now - this.lastVoluntaryProbeAt < this.voluntaryProbeMinIntervalMs
) {
return
}
this.lastVoluntaryProbeAt = now
this.startProbe(identity, urgent ? this.urgentProfile : this.ordinaryProfile)
this.startProbe(identity)
}
stop(identity: RpcSessionIdentity): void {
@@ -135,11 +109,9 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = null
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.lastInboundAt = 0
this.lastVoluntaryProbeAt = null
this.profile = this.ordinaryProfile
}
private armIdle(identity: RpcSessionIdentity, delayMs = this.idleProbeMs): void {
@@ -152,37 +124,21 @@ export class RpcSessionLivenessWatchdog {
if (this.identity !== identity) {
return
}
if (this.options.shouldIdleProbe && !this.options.shouldIdleProbe()) {
this.armIdle(identity)
return
}
const idleMs = this.now() - this.lastInboundAt
if (this.idleProbeMs !== null && idleMs < this.idleProbeMs) {
this.armIdle(identity, Math.max(1, this.idleProbeMs - Math.max(0, idleMs)))
} else {
this.startProbe(identity, this.ordinaryProfile, true)
this.startProbe(identity)
}
}, delayMs)
}
private startProbe(
identity: RpcSessionIdentity,
profile = this.ordinaryProfile,
fromIdleSweep = false
): void {
private startProbe(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.clearActiveTimer()
// Why: switching profile starts a new observation window on a different clock. Carrying the
// ordinary probe's misses into the urgent one spends the tolerated slow answer that profile
// exists to give a cold radio, so the first 2s miss would kill a healthy socket.
if (profile !== this.profile) {
this.missedProbes = 0
}
this.profile = profile
this.probing = true
this.idleSweepProbe = fromIdleSweep
const sentAt = this.now()
let sent = false
try {
@@ -194,7 +150,7 @@ export class RpcSessionLivenessWatchdog {
this.terminateCurrent(identity, 'probe-send-failed')
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), profile.timeoutMs)
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
}
private handleProbeTimeout(identity: RpcSessionIdentity, sentAt: number): void {
@@ -202,38 +158,27 @@ export class RpcSessionLivenessWatchdog {
if (this.identity !== identity) {
return
}
// Why: the idle sweep is foreground-only because iOS suspends sockets in the
// background, where a miss is not evidence of a dead peer. Retrying here would
// spend the whole miss budget on that suspension and kill a healthy session.
if (this.idleSweepProbe && this.options.shouldIdleProbe && !this.options.shouldIdleProbe()) {
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.armIdle(identity)
return
}
const profile = this.profile
const elapsedMs = this.now() - sentAt
if (elapsedMs < 0 || elapsedMs > profile.timeoutMs * 1.5) {
if (elapsedMs < 0 || elapsedMs > this.probeTimeoutMs * 1.5) {
console.log('[net] activity-probe unfair window skipped', {
transport: this.options.transport,
elapsedMs,
timeoutMs: profile.timeoutMs
timeoutMs: this.probeTimeoutMs
})
this.startProbe(identity, profile, this.idleSweepProbe)
this.startProbe(identity)
return
}
this.missedProbes += 1
if (this.missedProbes >= profile.missedProbeLimit) {
if (this.missedProbes >= this.missedProbeLimit) {
this.terminateCurrent(identity, 'probe-timeout')
return
}
console.log('[net] activity-probe timeout tolerated', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: profile.missedProbeLimit
missedProbeLimit: this.missedProbeLimit
})
this.startProbe(identity, profile, this.idleSweepProbe)
this.startProbe(identity)
}
private terminateCurrent(
@@ -246,17 +191,16 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = null
this.probing = false
this.idleSweepProbe = false
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: this.profile.missedProbeLimit
missedProbeLimit: this.missedProbeLimit
})
this.options.onTimeout?.({
transport: this.options.transport,
reason,
missedProbes: this.missedProbes,
missedProbeLimit: this.profile.missedProbeLimit,
missedProbeLimit: this.missedProbeLimit,
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
})
this.options.terminate(identity)
@@ -1,9 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
readRuntimeCapabilities,
startRuntimeCapabilityProbe,
startRuntimeStatusProbe
} from './runtime-status-probe'
import { startRuntimeCapabilityProbe } from './runtime-capability-probe'
import { LogicalClientCutoverError } from './stable-logical-rpc-client'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
@@ -129,46 +125,19 @@ describe('startRuntimeCapabilityProbe', () => {
cancel()
})
// Was: an ok:false response was retried like a timeout. The probe now backs the gate that sits
// above every /h/ route, so polling a host that already answered would run for the life of the
// connection. A reply is an answer; only an unanswered request is retried.
it('settles once on an ok:false response rather than polling the host', async () => {
it('retries an ok:false response instead of settling', async () => {
const failure: RpcResponse = {
ok: false,
id: '1',
error: { code: 'internal', message: 'nope' },
_meta: { runtimeId: 'r1' }
}
const { client, calls } = makeClient([failure, ok(['a.v1'])])
const { client } = makeClient([failure, ok(['a.v1'])])
const seen: (readonly string[])[] = []
const retrying: boolean[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(readRuntimeCapabilities(status)),
onUnavailable: (isRetrying) => retrying.push(isRetrying)
})
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
expect(retrying).toEqual([false])
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(60_000)
expect(calls()).toBe(1)
expect(seen).toEqual([])
cancel()
})
it('still retries a request the host never answered', async () => {
const { client, calls } = makeClient([new Error('timeout'), ok(['a.v1'])])
const seen: (readonly string[])[] = []
const retrying: boolean[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(readRuntimeCapabilities(status)),
onUnavailable: (isRetrying) => retrying.push(isRetrying)
})
await flushMicrotasks()
expect(retrying).toEqual([true])
await vi.advanceTimersByTimeAsync(1_000)
expect(calls()).toBe(2)
expect(seen).toEqual([['a.v1']])
cancel()
})
@@ -213,40 +182,4 @@ describe('startRuntimeCapabilityProbe', () => {
await flushMicrotasks()
expect(seen).toEqual([])
})
it('reports the full status, not just capabilities', async () => {
const response: RpcResponse = {
ok: true,
id: '1',
result: { appVersion: '1.4.0', protocolVersion: 7, capabilities: ['a.v1'] },
_meta: { runtimeId: 'r1' }
}
const { client } = makeClient([response])
const seen: Record<string, unknown>[] = []
const cancel = startRuntimeStatusProbe(client, { onStatus: (status) => seen.push(status) })
await flushMicrotasks()
expect(seen).toEqual([{ appVersion: '1.4.0', protocolVersion: 7, capabilities: ['a.v1'] }])
cancel()
})
// Why: the gate needs to release its pending cover on the first miss rather than wait out the
// retries, so a wedged status.get cannot hold the whole host UI behind a spinner.
it('announces each failed attempt while the retry is still pending', async () => {
const { client, calls } = makeClient([new Error('boom'), ok(['a.v1'])])
const misses: number[] = []
const seen: Record<string, unknown>[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(status),
onUnavailable: () => misses.push(calls())
})
await flushMicrotasks()
expect(misses).toEqual([1])
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(1_000)
await flushMicrotasks()
expect(seen).toEqual([{ capabilities: ['a.v1'] }])
expect(misses).toEqual([1])
cancel()
})
})
@@ -9,20 +9,9 @@ const CUTOVER_RETRY_DELAY_MS = 250
const FAILURE_RETRY_BASE_DELAY_MS = 1_000
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
export type RuntimeStatusProbeHandlers = {
onStatus: (status: Record<string, unknown>) => void
// Fires once per attempt that produced no status. `retrying` is false when the host itself
// answered with an error: that is a definitive reply, so the probe stops rather than polling a
// host that has already said no. It is true when nothing reached us and a retry is armed, which
// lets a caller that must not stay blocked fail open on the first miss and be upgraded later.
onUnavailable?: (retrying: boolean) => void
}
// Single status.get producer for a connected client: one request, retried until it
// lands. Callers share the answer instead of each issuing their own status.get.
export function startRuntimeStatusProbe(
client: Pick<RpcClient, 'sendRequest'>,
handlers: RuntimeStatusProbeHandlers
export function startRuntimeCapabilityProbe(
client: RpcClient,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
@@ -35,15 +24,20 @@ export function startRuntimeStatusProbe(
return
}
if (!response.ok) {
// Why not retry: the desktop replied. Re-asking every 15 s for the life of a connection
// from a probe mounted above every /h/ route buys nothing a reconnect would not.
handlers.onUnavailable?.(false)
scheduleRetry(false)
return
}
const result = (response as RpcSuccess).result
handlers.onStatus(
result && typeof result === 'object' ? (result as Record<string, unknown>) : {}
)
const rawCapabilities =
result && typeof result === 'object'
? (result as { capabilities?: unknown }).capabilities
: null
const capabilities =
Array.isArray(rawCapabilities) &&
rawCapabilities.every((value) => typeof value === 'string')
? rawCapabilities
: []
onCapabilities(capabilities)
},
(error: unknown) => {
if (cancelled) {
@@ -61,7 +55,6 @@ export function startRuntimeStatusProbe(
? CUTOVER_RETRY_DELAY_MS
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
retryTimer = setTimeout(attempt, delay)
handlers.onUnavailable?.(true)
}
attempt()
@@ -72,17 +65,3 @@ export function startRuntimeStatusProbe(
}
}
}
export function readRuntimeCapabilities(status: Record<string, unknown>): readonly string[] {
const raw = status.capabilities
return Array.isArray(raw) && raw.every((value) => typeof value === 'string') ? raw : []
}
export function startRuntimeCapabilityProbe(
client: Pick<RpcClient, 'sendRequest'>,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
return startRuntimeStatusProbe(client, {
onStatus: (status) => onCapabilities(readRuntimeCapabilities(status))
})
}
@@ -13,7 +13,7 @@ import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client'
const ACTIVE_STATUSES = new Set(['working', 'active', 'permission'])
// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the
// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop
// can't spin. See runtime-status-probe.ts for the same hazard on status.get.
// can't spin. See runtime-capability-probe.ts for the same hazard on status.get.
const CUTOVER_RETRY_LIMIT = 2
export type HostWorktreeInfoSetter = (
+5 -3
View File
@@ -134,6 +134,7 @@
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:spinners": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/spinner-rendering/run.mjs",
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/daemon-coldstart-bench.mjs",
@@ -176,7 +177,7 @@
"proper-lockfile": "4.1.2",
"psl": "1.15.0",
"qrcode": "^1.5.4",
"react-i18next": "^17.0.8",
"react-i18next": "17.0.13",
"serve-sim": "^0.1.40",
"sherpa-onnx": "1.12.37",
"ssh2": "^1.17.0",
@@ -235,13 +236,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.4.13",
"electron": "^43.4.1",
"dompurify": "3.4.14",
"electron": "43.6.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
"electron-vite": "^5.0.0",
"emoji-picker-react": "^4.19.1",
"emojibase-data": "17.0.0",
"esbuild": "^0.25.12",
"happy-dom": "^20.11.8",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
+113 -116
View File
@@ -106,7 +106,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
monaco-editor>dompurify: 3.4.13
monaco-editor>dompurify: 3.4.14
patchedDependencies:
'@vscode/windows-process-tree@0.8.0': e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7
@@ -114,7 +114,7 @@ patchedDependencies:
'@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
'@xterm/xterm@6.1.0-beta.303': 1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615
@@ -127,10 +127,10 @@ importers:
version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@43.4.1(supports-color@7.2.0))
version: 3.0.2(electron@43.6.0(supports-color@7.2.0))
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@43.4.1(supports-color@7.2.0))
version: 4.0.0(electron@43.6.0(supports-color@7.2.0))
'@floating-ui/dom':
specifier: 1.7.6
version: 1.7.6
@@ -142,7 +142,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/headless':
specifier: 6.1.0-beta.302
version: 6.1.0-beta.302
@@ -174,8 +174,8 @@ importers:
specifier: ^1.5.4
version: 1.5.4
react-i18next:
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
specifier: 17.0.13
version: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
serve-sim:
specifier: ^0.1.40
version: 0.1.40(typescript@7.0.2)
@@ -317,25 +317,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.300
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.300
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-search':
specifier: 0.17.0-beta.300
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.300
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/xterm':
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
version: 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -346,11 +346,11 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
dompurify:
specifier: ^3.4.13
version: 3.4.13
specifier: 3.4.14
version: 3.4.14
electron:
specifier: ^43.4.1
version: 43.4.1(supports-color@7.2.0)
specifier: 43.6.0
version: 43.6.0(supports-color@7.2.0)
electron-builder:
specifier: ^26.15.3
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
@@ -366,6 +366,9 @@ importers:
emojibase-data:
specifier: 17.0.0
version: 17.0.0(emojibase@17.0.0)
esbuild:
specifier: ^0.25.12
version: 0.25.12
happy-dom:
specifier: ^20.11.8
version: 20.11.8
@@ -3513,8 +3516,8 @@ packages:
'@vscode/windows-process-tree@0.8.0':
resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.12.0-beta.300':
@@ -3677,8 +3680,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
baseline-browser-mapping@2.11.21:
resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3715,8 +3718,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.9:
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3779,8 +3782,8 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4261,8 +4264,8 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
dompurify@3.4.14:
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
@@ -4306,8 +4309,8 @@ packages:
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron-to-chromium@1.5.351:
resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==}
electron-to-chromium@1.5.422:
resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==}
electron-updater@6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
@@ -4327,8 +4330,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@43.4.1:
resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==}
electron@43.6.0:
resolution: {integrity: sha512-DqVKYV+FXheMSLTxcMQ+NCo78BDgpnToSyIzXctlUtbP3lRGEuoo1P+C2n/90rJ7TvHgzP0bpP9fbbXxp4noIg==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -4525,8 +4528,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-uri@3.1.7:
resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
@@ -4794,8 +4797,8 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
@@ -5563,8 +5566,8 @@ packages:
nan@2.26.2:
resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -5600,8 +5603,9 @@ packages:
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
nopt@9.0.0:
resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
@@ -5856,8 +5860,8 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss@8.5.25:
@@ -5977,8 +5981,8 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -6029,14 +6033,14 @@ packages:
react:
optional: true
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
react-i18next@17.0.13:
resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
react-dom:
optional: true
@@ -6392,8 +6396,8 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
@@ -6755,8 +6759,8 @@ packages:
unzipper@0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
update-browserslist-db@1.3.2:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -6854,10 +6858,6 @@ packages:
jsdom:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
vscode-oniguruma@2.0.1:
resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==}
@@ -7119,7 +7119,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.9
lru-cache: 5.1.1
semver: 6.3.1
@@ -7332,17 +7332,17 @@ snapshots:
'@electron-internal/extract-zip@1.0.4': {}
'@electron-toolkit/preload@3.0.2(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/preload@3.0.2(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)':
dependencies:
'@types/node': 25.9.5
'@electron-toolkit/utils@4.0.0(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/utils@4.0.0(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron/asar@3.4.1':
dependencies:
@@ -7739,7 +7739,7 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -7761,7 +7761,7 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -9826,41 +9826,41 @@ snapshots:
node-addon-api: 7.1.0
optional: true
'@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.8.15': {}
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/headless@6.1.0-beta.302': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)': {}
abbrev@4.0.0: {}
@@ -9882,7 +9882,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
fast-uri: 3.1.7
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -9994,7 +9994,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.11.21: {}
bcrypt-pbkdf@1.0.2:
dependencies:
@@ -10014,7 +10014,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.2
qs: 6.16.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
@@ -10040,13 +10040,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.9:
dependencies:
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001791
electron-to-chromium: 1.5.351
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
baseline-browser-mapping: 2.11.21
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.422
node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.9)
buffer-from@1.1.2: {}
@@ -10119,7 +10119,7 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001791: {}
caniuse-lite@1.0.30001810: {}
ccount@2.0.1: {}
@@ -10573,7 +10573,7 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dompurify@3.4.13:
dompurify@3.4.14:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10647,7 +10647,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-to-chromium@1.5.351: {}
electron-to-chromium@1.5.422: {}
electron-updater@6.8.9(supports-color@7.2.0):
dependencies:
@@ -10688,7 +10688,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@43.4.1(supports-color@7.2.0):
electron@43.6.0(supports-color@7.2.0):
dependencies:
'@electron-internal/extract-zip': 1.0.4
'@electron/get': 5.0.0(supports-color@7.2.0)
@@ -10862,7 +10862,7 @@ snapshots:
exponential-backoff@3.1.3: {}
express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)):
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1(supports-color@7.2.0)
ip-address: 10.4.0
@@ -10889,7 +10889,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.2
qs: 6.16.0
range-parser: 1.2.1
router: 2.2.0(supports-color@7.2.0)
send: 1.2.1(supports-color@7.2.0)
@@ -10922,7 +10922,7 @@ snapshots:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.5: {}
fast-uri@3.1.7: {}
fast-wrap-ansi@0.2.2:
dependencies:
@@ -11299,9 +11299,7 @@ snapshots:
dependencies:
lru-cache: 6.0.0
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html-parse-stringify@4.0.1: {}
html-to-image@1.11.13: {}
@@ -11361,7 +11359,7 @@ snapshots:
minimatch: 10.2.5
ora: 9.4.0
react: 19.2.8
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
react-i18next: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
@@ -11929,7 +11927,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.23
dompurify: 3.4.13
dompurify: 3.4.14
es-toolkit: 1.46.1
fastdom: 1.0.12
katex: 0.16.47
@@ -12214,7 +12212,7 @@ snapshots:
monaco-editor@0.55.1:
dependencies:
dompurify: 3.4.13
dompurify: 3.4.14
marked: 14.0.0
ms@2.1.3: {}
@@ -12250,7 +12248,7 @@ snapshots:
nan@2.26.2:
optional: true
nanoid@3.3.17: {}
nanoid@3.3.18: {}
negotiator@1.0.0: {}
@@ -12289,7 +12287,7 @@ snapshots:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.38: {}
node-releases@2.0.54: {}
nopt@9.0.0:
dependencies:
@@ -12575,7 +12573,7 @@ snapshots:
plist@3.1.0:
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
@@ -12590,14 +12588,14 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
postcss-selector-parser@7.1.1:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.25:
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -12743,9 +12741,10 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.2:
qs@6.16.0:
dependencies:
side-channel: 1.1.0
es-define-property: 1.0.1
side-channel: 1.1.1
queue-microtask@1.2.3: {}
@@ -12840,10 +12839,10 @@ snapshots:
optionalDependencies:
react: 19.2.8
react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
react-i18next@17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
html-parse-stringify: 4.0.1
i18next: 26.3.1(typescript@7.0.2)
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
@@ -13222,7 +13221,7 @@ snapshots:
'@dotenvx/dotenvx': 1.66.0
'@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
browserslist: 4.28.9
commander: 14.0.3
cosmiconfig: 9.0.1(typescript@7.0.2)
dedent: 1.7.2
@@ -13236,7 +13235,7 @@ snapshots:
open: 11.0.0
ora: 8.2.0
postcss: 8.5.25
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.6
prompts: 2.4.2
recast: 0.23.11
stringify-object: 5.0.0
@@ -13296,7 +13295,7 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -13668,9 +13667,9 @@ snapshots:
graceful-fs: 4.2.11
node-int64: 0.4.0
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.3.2(browserslist@4.28.9):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.9
escalade: 3.2.0
picocolors: 1.1.1
@@ -13747,8 +13746,6 @@ snapshots:
transitivePeerDependencies:
- msw
void-elements@3.1.0: {}
vscode-oniguruma@2.0.1: {}
vscode-textmate@9.3.2: {}
+1 -1
View File
@@ -52,7 +52,7 @@ allowBuilds:
windows-native-registry: false
overrides:
monaco-editor>dompurify: 3.4.13
monaco-editor>dompurify: 3.4.14
patchedDependencies:
node-pty@1.1.0: config/patches/node-pty@1.1.0.patch
+6 -6
View File
@@ -213,9 +213,9 @@ The commands, snapshot and ref rules, page affinity, and `browser_*` recoveries
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference from the same CLI build, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
| Action gate | Reference |
|---|---|
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
| Action gate | Reference |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
+17 -17
View File
@@ -52,23 +52,23 @@ Orca returns a clear message when the SDK is missing
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
| Goal | Command | Constraint |
| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
## Targeting
+15 -15
View File
@@ -43,20 +43,20 @@ Orca reports a clear error when the host is missing macOS or the Xcode tools.
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Hardware button | `ORCA emulator button home --json` | `home` and `side_button` are documented by the CLI spec; other names such as `swipe_home`, `app_switcher`, `lock`, and `siri` are forwarded to serve-sim unvalidated. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
## Targeting
@@ -65,8 +65,8 @@ commands target it. Pass a selector only to override that or reach a second devi
active session an unqualified command fails with `emulator_no_active`; attach or open the pane
and retry.
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`. `--emulator
<id>` is an alternative spelling: the bridge resolves both through the same lookup. These
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`.
`--emulator <id>` is an alternative spelling: the bridge resolves both through the same lookup. These
selectors apply to the action verbs; `list` and `devices` take only `--worktree`, and
`attach` names its device as a positional argument.
- `--worktree id:<fullWorktreeId>` or `--worktree active`. The full id is the exact
+7 -7
View File
@@ -367,10 +367,10 @@ rejects `--reference`, run `ORCA skills get orca-per-workspace-env --full` once
this guide plus every reference from the same CLI build, so read only the named one. If `--full` is
rejected too, keep these rules, use the command's `--help`, and do not guess flags.
| Action gate | Bundled reference |
| --- | --- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
| Action gate | Bundled reference |
| ----------------------------------------------------------------------------------------- | ------------------------------- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
+2 -2
View File
@@ -137,8 +137,8 @@ After three consecutive empty waits, stop waiting blindly and enumerate with
`ORCA orchestration worker-list --include-remote --json` (defaults to the bound
Run; `--run <run_id>` overrides; the receipt's `scope` names which), acting on
each row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.
An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false
is informational, not a command to re-run: keep waiting with `check --wait`.
A `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting
with `check --wait`. Absence never earns an argv; settlement and pending work still do.
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
worker's own observation of process exit, or a transcript whose final agent turn
sent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose
File diff suppressed because one or more lines are too long
+23 -6
View File
@@ -74,21 +74,38 @@ export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"'
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul`
// The Orca context a hook needs before it may own stdin; see the rule below.
const WINDOWS_HOOK_ENVIRONMENT_VARS = [
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_PANE_KEY'
] as const
// Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller
// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a
// visible window per hook event. The Windows rule: a hook must check the Orca env before it
// owns stdin, and exit without reading when the env is missing — the payload is discarded on
// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike.
// that path anyway. This applies to .cmd, the copilot .ps1, and the Git Bash kimi .sh alike,
// and to the launchers that own stdin themselves when the managed script is missing.
// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there
// surfaces as EPIPE the agent can see (#8110).
export function buildWindowsHookEnvironmentGuardLines(): string[] {
return [
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0'
]
return WINDOWS_HOOK_ENVIRONMENT_VARS.map((name) => `if "%${name}%"=="" exit /b 0`)
}
/** The same guard in sh, for the Git Bash hooks and launchers that run on Windows.
* Default-formed because a static hook precheck (Grok) rejects a bare reference it
* cannot resolve. POSIX hosts keep capture-first — this is the Windows rule only. */
export const WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD = `if ${WINDOWS_HOOK_ENVIRONMENT_VARS.map(
(name) => `[ -z "\${${name}-}" ]`
).join(' || ')}; then exit 0; fi`
/** The same guard for a PowerShell hook or launcher. Anything that reaches
* `[Console]::In.ReadToEnd()` must run this first, or it inherits #11549. */
export const WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD = `if (${WINDOWS_HOOK_ENVIRONMENT_VARS.map(
(name) => `-not $env:${name}`
).join(' -or ')}) { exit 0 }`
export function buildWindowsHookStdinDrainEpilogue(): string[] {
return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0']
}
+24 -10
View File
@@ -31,7 +31,10 @@ import {
type HooksConfig
} from './installer-utils'
import { buildPosixAgentHookPostCommand } from './hook-post-command'
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import {
POSIX_HOOK_STDIN_DRAIN_COMMAND,
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
} from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
let tmpDir: string
@@ -618,7 +621,10 @@ function expectedDecodedWindowsHookCommand(scriptPath: string): string {
// Why: the execution-policy bypass rides in the payload, not on the command
// line, so the launcher cannot spell the AV-blocked flag triple (#16003).
// Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout.
return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
// Why the guard is spelled by import: the launcher owns stdin on the missing-script path,
// so it obeys the shared Windows rule (#11549), and re-typing it here would let the two
// drift back apart.
return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
}
describe('wrapWindowsHookCommand', () => {
@@ -640,15 +646,23 @@ describe('wrapWindowsHookCommand', () => {
)
})
it('emits fallback stdout when the managed script is missing', () => {
const command = wrapWindowsHookCommand(
'C:\\hooks\\cursor-hook.cmd',
{},
{ fallbackStdout: '{"permission":"allow"}' }
)
expect(decodeWindowsHookCommand(command)).toContain(
'Write-Output \'{"permission":"allow"}\'; exit 0'
// Why the ordering matters: a gate event reads silence as deny (#2426), and outside an
// Orca pane the guard exits before the read — so an answer placed after the drain never
// reaches the agent at all when the caller abandons the pipe (#11549).
it('answers before it guards, and guards before it owns stdin', () => {
const decoded = decodeWindowsHookCommand(
wrapWindowsHookCommand(
'C:\\hooks\\cursor-hook.cmd',
{},
{ fallbackStdout: '{"permission":"allow"}' }
)
)
const answer = decoded.indexOf('Write-Output \'{"permission":"allow"}\'')
const guard = decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
const ownsStdin = decoded.indexOf('[Console]::In.ReadToEnd()')
expect(answer).toBeGreaterThan(-1)
expect(guard).toBeGreaterThan(answer)
expect(ownsStdin).toBeGreaterThan(guard)
})
// Why: a user profile path like `C:\Users\Jane Doe` is the regression from
+5 -1
View File
@@ -16,6 +16,7 @@ import { grantDirAcl, isPermissionError } from '../win32-utils'
import { resolveHooksJsonWritePath } from './hook-config-write-path'
import { writeRollingFileBackup } from '../rolling-file-backup'
import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher'
import { WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD } from './hook-stdin-contract'
export type HookCommandConfig = {
type: 'command'
@@ -131,7 +132,10 @@ export function wrapWindowsHookCommand(
options.fallbackStdout === undefined
? ''
: `Write-Output ${quotePowerShellString(options.fallbackStdout)}; `
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; ${fallback}exit 0`
// Why the order: answer first (a gate event reads silence as deny), then the shared
// env guard, and only then own stdin — outside an Orca pane the caller may abandon the
// pipe, and ReadToEnd would strand the launcher there forever (#11549).
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
return wrapWindowsPowerShellEncodedCommand(command)
}
@@ -63,12 +63,26 @@ import { KimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
import {
POSIX_HOOK_STDIN_READER,
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
} from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
import { findGitBash } from './windows-git-bash-path.test-fixture'
/** The launchers ship their command base64'd; assert the shape they actually run. */
function decodeEncodedPowerShellCommand(command: string): string {
const encoded = command.match(/-EncodedCommand\s+(\S+)/)
expect(encoded, 'launcher carries an encoded command').not.toBeNull()
return Buffer.from(encoded![1], 'base64').toString('utf16le')
}
const REMOTE_HOME = '/home/dev'
// Why all three: Windows reports a write to a pipe whose reader is gone as any of these,
// depending on whether the read handle, the pipe, or the process went first. Enumerating
// them keeps the guard-exit legs from failing on which race the host happened to run.
const WRITER_BROKEN_BY_EARLY_EXIT = ['EPIPE', 'ECONNRESET', 'EOF']
const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x')
// Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before any
@@ -156,7 +170,10 @@ type HookRun = {
function runHookProcess(
executable: string,
args: string[],
env: NodeJS.ProcessEnv
env: NodeJS.ProcessEnv,
// Why: `abandon` leaves the pipe open and unwritten — the shape a caller outside an Orca
// pane produces, and the only one that can catch a read-to-EOF that never returns (#11549).
stdin: 'close' | 'abandon' = 'close'
): Promise<HookRun> {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
@@ -164,8 +181,9 @@ function runHookProcess(
let stderr = ''
let stdout = ''
const timeout = setTimeout(() => {
child.stdin.destroy()
child.kill('SIGKILL')
reject(new Error('hook did not finish after stdin closed'))
reject(new Error(`hook did not finish with stdin ${stdin}d`))
}, 10_000)
child.on('error', (error) => {
clearTimeout(timeout)
@@ -182,7 +200,9 @@ function runHookProcess(
clearTimeout(timeout)
resolve({ exitCode, stdinErrors, stderr, stdout })
})
child.stdin.end(LARGE_PAYLOAD)
if (stdin === 'close') {
child.stdin.end(LARGE_PAYLOAD)
}
})
}
@@ -303,6 +323,31 @@ describe('Windows managed hook stdin structure', () => {
expect(copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')).toBeLessThan(
copilot.indexOf('[Console]::In.ReadToEnd()')
)
// Why: the two encoded-PowerShell launchers own stdin themselves when the managed
// script is missing, so the same guard has to precede their ReadToEnd — and the
// fallback answer has to precede the guard, or a gate event outside a pane is
// answered with silence, which reads as deny (#2426/#15462).
for (const [name, command] of [
[
'wrapWindowsHookCommand',
wrapWindowsHookCommand('C:\\missing\\orca-hook.cmd', {}, { fallbackStdout: '{}' })
],
[
'wrapRuntimeHomeHookCommand',
wrapRuntimeHomeHookCommand('missing-orca-hook', { neutralJsonWhenMissing: true })
]
] as const) {
const decoded = decodeEncodedPowerShellCommand(command)
expect(decoded, `${name} decoded`).toContain(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
expect(decoded.indexOf("Write-Output '{}'"), `${name} answers first`).toBeLessThan(
decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD)
)
expect(
decoded.indexOf(WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD),
`${name} guards before owning stdin`
).toBeLessThan(decoded.indexOf('[Console]::In.ReadToEnd()'))
}
const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8')
expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeGreaterThan(-1)
expect(kimi.indexOf('if [ -z "$ORCA_AGENT_HOOK_PORT" ]')).toBeLessThan(
@@ -365,12 +410,11 @@ describe('Windows managed hook stdin structure', () => {
const result = await runHookProcess(executable, args, hookEnvironment())
expect(result.exitCode, `${fileName} exit code`).toBe(0)
// Why (#11549 class): every Windows-local hook exits before owning stdin when the
// Orca env is missing, so the writer may break — EPIPE, or ECONNRESET when Windows
// tears the pipe down first. hookEnvironment() strips every ORCA_* var, so this
// relaxation only ever covers the missing-env path — a happy-path case added to
// this loop must not reuse it.
// Orca env is missing, so the writer may break. hookEnvironment() strips every
// ORCA_* var, so this relaxation only ever covers the missing-env path — a
// happy-path case added to this loop must not reuse it.
for (const error of result.stdinErrors) {
expect(['EPIPE', 'ECONNRESET'], `${fileName} stdin error`).toContain(error.code)
expect(WRITER_BROKEN_BY_EARLY_EXIT, `${fileName} stdin error`).toContain(error.code)
}
}
@@ -395,9 +439,42 @@ describe('Windows managed hook stdin structure', () => {
}
]
for (const launcher of launcherCases) {
const result = await runHookProcess(launcher.executable, launcher.args, hookEnvironment())
expect(result.exitCode, `${launcher.name} exit code`).toBe(0)
expect(result.stdinErrors, `${launcher.name} stdin errors`).toHaveLength(0)
// Why (#11549 class): a launcher that reaches an interpreter owns stdin for a
// missing script exactly like a managed script does, so it obeys the same rule —
// drain inside a pane, exit before reading outside one. Its writer may therefore
// break on the missing-env leg, and must not on the in-pane leg.
const outside = await runHookProcess(
launcher.executable,
launcher.args,
hookEnvironment()
)
expect(outside.exitCode, `${launcher.name} exit code`).toBe(0)
for (const error of outside.stdinErrors) {
expect(WRITER_BROKEN_BY_EARLY_EXIT, `${launcher.name} stdin error`).toContain(
error.code
)
}
const insideAPane = await runHookProcess(
launcher.executable,
launcher.args,
hookEnvironment({
ORCA_AGENT_HOOK_PORT: '59999',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_PANE_KEY: 'tab:leaf'
})
)
expect(insideAPane.exitCode, `${launcher.name} in-pane exit code`).toBe(0)
expect(insideAPane.stdinErrors, `${launcher.name} in-pane stdin errors`).toHaveLength(0)
// Why this leg and not a shape assertion: an unguarded ReadToEnd exits fine when
// the writer closes the pipe. Only a caller that abandons it strands the launcher,
// which is what left a console per hook event on the reporting hosts.
const abandoned = await runHookProcess(
launcher.executable,
launcher.args,
hookEnvironment(),
'abandon'
)
expect(abandoned.exitCode, `${launcher.name} abandoned-stdin exit code`).toBe(0)
}
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
@@ -1,4 +1,8 @@
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import {
POSIX_HOOK_STDIN_DRAIN_COMMAND,
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
} from './hook-stdin-contract'
import {
encodeWindowsPowerShellHookCommand,
WINDOWS_POWERSHELL_HOOK_SWITCHES
@@ -19,16 +23,30 @@ export function wrapRuntimeHomeHookCommand(
const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"`
const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"`
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
const missingScriptFallback = options.neutralJsonWhenMissing ? `${drain}; printf '{}\\n'` : drain
const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : ''
// Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host
// it lands on. POSIX callers close the pipe, so capture-first is safe there and a mid-write
// exit stays visible as EPIPE (#8110). A Windows caller may abandon the pipe, so there the
// answer comes first and the drain only runs with an Orca env behind it (#11549).
const posixMissingScriptFallback = neutralJson ? `${drain}; ${neutralJson}` : drain
const windowsMissingScriptFallback = [
...(neutralJson ? [neutralJson] : []),
WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD,
drain
].join('; ')
// Why platform-selected even when HOME is unset: which stdin rule applies follows the
// caller, not the reason the script could not be found.
const missingScriptFallback = `case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsMissingScriptFallback} ;; *) ${posixMissingScriptFallback} ;; esac`
const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"'
const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : ''
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0`
// Why the order: answer first, then the shared env guard, then own stdin — see wrapWindowsHookCommand.
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }${powershellFallback}; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand)
// Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path.
const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}`
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi`
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${missingScriptFallback}; fi`
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi`
const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi`
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi`
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
}
+6 -3
View File
@@ -88,7 +88,10 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
export function getWindowsWrapperScript(eventName: string): string {
return [
'@echo off',
'setlocal',
// Why (#9358/#9941): `!` is legal in the hooks path, and inherited delayed expansion
// eats it out of the percent-expanded `%~dp0` — the wrapper then misses the core and
// silently falls back on every event. Same reason the core disables it.
'setlocal DisableDelayedExpansion',
`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`,
'set "ORCA_ANTIGRAVITY_CORE=%~dp0antigravity-hook.cmd"',
'if exist "%ORCA_ANTIGRAVITY_CORE%" (',
@@ -102,8 +105,8 @@ export function getWindowsWrapperScript(eventName: string): string {
') else (',
' echo {}',
')',
// Why: when the shared core script is missing, this wrapper becomes the
// stdin owner and must finish the agent's payload write before returning.
// Missing-core fallbacks obey the same outside-Orca stdin guard as the core.
...buildWindowsHookEnvironmentGuardLines(),
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
'exit /b 0',
''
@@ -28,7 +28,8 @@ vi.mock('os', async (importOriginal) => {
import { AntigravityHookService } from './hook-service'
import { ANTIGRAVITY_EVENTS, ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
import { getManagedScript } from './hook-script'
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
import { WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract'
// Why (#9358/#9941): `!` is legal in a Windows path and in a pane key. Under inherited
// delayed expansion cmd eats it out of a percent-expanded curl argument, so bake one into
@@ -91,17 +92,23 @@ async function startHookListener(): Promise<{
type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }
// Why spell `/v`: `cmd /d /c <bare .cmd path>` is the chain in the bug report's process trace,
// and it inherits HKCU\...\Command Processor\DelayedExpansion. Naming the state makes the
// hostile half reachable on any host — under `/v:on` cmd eats `!` out of every percent
// expansion (#9358/#9941), and a harness pinned to `/v:off` could never fail on it.
type DelayedExpansion = 'on' | 'off'
const DELAYED_EXPANSION_STATES = ['off', 'on'] as const satisfies readonly DelayedExpansion[]
function runWrapper(
wrapperPath: string,
env: NodeJS.ProcessEnv,
// Why: `null` abandons stdin instead of closing it — the shape a caller outside an Orca
// pane produces, and the only way to prove the env guard exits before reading (#11549).
stdinPayload: string | null = PAYLOAD
stdinPayload: string | null = PAYLOAD,
delayedExpansion: DelayedExpansion = 'off'
): Promise<HookRun> {
return new Promise((resolve, reject) => {
// Why: mirror how Antigravity spawns the hook — `cmd /c <bare .cmd path>`, the exact
// chain in the bug report's process trace.
const child = spawn('cmd.exe', ['/d', '/c', wrapperPath], {
const child = spawn('cmd.exe', [`/v:${delayedExpansion}`, '/d', '/c', wrapperPath], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env
@@ -111,6 +118,7 @@ function runWrapper(
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.stdin.destroy()
child.kill('SIGKILL')
}, 15_000)
child.on('error', (error) => {
@@ -154,6 +162,24 @@ function expectedStdout(eventName: string): string {
// Why: runs on every platform — the live delivery suite below is Windows-only, so this
// keeps a POSIX-only CI leg from letting the interpreter back into the hot path.
describe('Antigravity Windows hook post command', () => {
it.each(ANTIGRAVITY_EVENTS)('guards missing-core stdin for $eventName', ({ eventName }) => {
const script = getWindowsWrapperScript(eventName)
const drain = script.indexOf(WINDOWS_HOOK_STDIN_DRAIN_COMMAND)
const answer = script.lastIndexOf('echo {}')
expect(drain).toBeGreaterThan(answer)
for (const key of ['ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY']) {
const guard = script.indexOf(`if "%${key}%"=="" exit /b 0`)
expect(guard, key).toBeGreaterThan(answer)
expect(guard, key).toBeLessThan(drain)
}
})
// Why (#9358/#9941): `%~dp0` carries the hooks path, so an inherited delayed expansion eats
// a `!` out of it and the wrapper silently misses the core on every event.
it.each(ANTIGRAVITY_EVENTS)('disables delayed expansion for $eventName', ({ eventName }) => {
expect(getWindowsWrapperScript(eventName)).toContain('setlocal DisableDelayedExpansion')
})
it('posts through curl.exe rather than a PowerShell interpreter', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const script = getManagedScript('local')
@@ -185,7 +211,10 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
})
it('delivers every event wrapper payload to the listener without spawning PowerShell', async () => {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
// Why the `!` in the directory: it lands in the wrapper's `%~dp0`, which is what an
// inherited delayed expansion eats (#9358/#9941). Without it the `/v:on` leg below
// proves nothing about the core lookup.
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook!bang-'))
homedirMock.mockReturnValue(home)
expect(new AntigravityHookService().install().state).toBe('installed')
@@ -204,34 +233,42 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
ORCA_WORKTREE_ID: WORKTREE_ID
})
for (const event of ANTIGRAVITY_EVENTS) {
const label = event.eventName
const before = listener.posts.length
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env)
for (const delayedExpansion of DELAYED_EXPANSION_STATES) {
for (const event of ANTIGRAVITY_EVENTS) {
const label = `${event.eventName} (/v:${delayedExpansion})`
const before = listener.posts.length
const result = await runWrapper(
join(hooksDir, event.windowsWrapperFileName),
env,
PAYLOAD,
delayedExpansion
)
expect(result.timedOut, `${label} timed out`).toBe(false)
expect(result.exitCode, `${label} exit code`).toBe(0)
expect(result.stderr, `${label} stderr`).toBe('')
// Why: Antigravity reads silence on PreToolUse as deny (#2426), so the gate answer
// must survive the transport change.
expect(result.stdout.trim(), `${label} stdout`).toBe(expectedStdout(label))
expect(result.timedOut, `${label} timed out`).toBe(false)
expect(result.exitCode, `${label} exit code`).toBe(0)
expect(result.stderr, `${label} stderr`).toBe('')
// Why: Antigravity reads silence on PreToolUse as deny (#2426), so the gate answer
// must survive the transport change.
expect(result.stdout.trim(), `${label} stdout`).toBe(expectedStdout(event.eventName))
const posts = listener.posts.slice(before)
expect(posts, `${label} posted exactly one hook`).toHaveLength(1)
// Why: byte-exact, not "non-empty" — PowerShell recoded this body through the console
// code page, and a silently corrupted payload still looks posted.
expect(posts[0].payload, `${label} payload`).toBe(PAYLOAD)
expect(posts[0].hookEventName, `${label} hook_event_name`).toBe(label)
// Why: the `!` in both values is the delayed-expansion regression guard.
expect(posts[0].paneKey, `${label} paneKey`).toBe(PANE_KEY)
expect(posts[0].worktreeId, `${label} worktreeId`).toBe(WORKTREE_ID)
expect(posts[0].token, `${label} token`).toBe(HOOK_TOKEN)
expect(posts[0].contentType, `${label} content-type`).toContain(
'application/x-www-form-urlencoded'
)
const posts = listener.posts.slice(before)
expect(posts, `${label} posted exactly one hook`).toHaveLength(1)
// Why: byte-exact, not "non-empty" — PowerShell recoded this body through the console
// code page, and a silently corrupted payload still looks posted.
expect(posts[0].payload, `${label} payload`).toBe(PAYLOAD)
expect(posts[0].hookEventName, `${label} hook_event_name`).toBe(event.eventName)
// Why: the `!` in both values is the delayed-expansion regression guard — it is the
// `/v:on` leg that can actually fail on it.
expect(posts[0].paneKey, `${label} paneKey`).toBe(PANE_KEY)
expect(posts[0].worktreeId, `${label} worktreeId`).toBe(WORKTREE_ID)
expect(posts[0].token, `${label} token`).toBe(HOOK_TOKEN)
expect(posts[0].contentType, `${label} content-type`).toContain(
'application/x-www-form-urlencoded'
)
}
}
// Why: five wrapper launches plus a real install can overrun the default under load.
}, 60_000)
// Why: ten wrapper launches plus a real install can overrun the default under load.
}, 90_000)
// Why (#15117): Antigravity fires some events with no stdin at all. PowerShell substituted
// `{}` before posting; curl forwards the empty body, so prove the post still happens — the
@@ -264,6 +301,67 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload
expect(listener.posts[0].hookEventName).toBe('PreInvocation')
}, 30_000)
// Why a helper: the missing-core cases all need a real install with the core removed, which
// is the shape an AV quarantine or a half-finished uninstall leaves behind.
async function installWithoutCore(): Promise<string> {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-fallback-'))
homedirMock.mockReturnValue(home)
expect(new AntigravityHookService().install().state).toBe('installed')
const hooksDir = join(home, '.orca', 'agent-hooks')
rmSync(join(hooksDir, 'antigravity-hook.cmd'))
return hooksDir
}
it.each(['ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY'])(
'answers every missing-core event with abandoned stdin and no %s',
async (missingKey) => {
const hooksDir = await installWithoutCore()
const listener = await startHookListener()
server = listener.server
const env = hookEnvironment({
USERPROFILE: home,
HOME: home,
ORCA_AGENT_HOOK_PORT: String(listener.port),
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
ORCA_PANE_KEY: PANE_KEY,
[missingKey]: ''
})
for (const event of ANTIGRAVITY_EVENTS) {
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env, null)
expect(result.timedOut, event.eventName).toBe(false)
expect(result.exitCode, event.eventName).toBe(0)
expect(result.stdout.trim(), event.eventName).toBe(expectedStdout(event.eventName))
expect(result.stderr, event.eventName).toBe('')
}
expect(listener.posts).toHaveLength(0)
},
90_000
)
// Why: the guard must not cost the valid path its drain — with the Orca env present the
// fallback still owns stdin, so the agent's payload write completes instead of breaking.
it('still drains a closed payload for every missing-core event inside a pane', async () => {
const hooksDir = await installWithoutCore()
const listener = await startHookListener()
server = listener.server
const env = hookEnvironment({
USERPROFILE: home,
HOME: home,
ORCA_AGENT_HOOK_PORT: String(listener.port),
ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN,
ORCA_PANE_KEY: PANE_KEY
})
for (const event of ANTIGRAVITY_EVENTS) {
const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env)
expect(result.timedOut, event.eventName).toBe(false)
expect(result.exitCode, event.eventName).toBe(0)
expect(result.stdout.trim(), event.eventName).toBe(expectedStdout(event.eventName))
expect(result.stderr, event.eventName).toBe('')
}
// Why: the fallback answers the agent but has no core to post through.
expect(listener.posts).toHaveLength(0)
}, 60_000)
it('exits without reading stdin when the pane env is missing', async () => {
home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-'))
homedirMock.mockReturnValue(home)
+6 -6
View File
@@ -71,7 +71,7 @@ function mapExternalRuns({
.map((run, index) => {
const runAt = asString(run.run_at) ?? asString(run.runAt)
const id = asString(run.id) ?? `${jobId}:${runAt ?? index}`
return {
const mapped: ExternalAutomationRun = {
id,
managerId,
provider,
@@ -83,15 +83,15 @@ function mapExternalRuns({
error: asString(run.error),
outputPath: asString(run.output_path) ?? asString(run.outputPath)
}
return { run: mapped, time: runAt ? Date.parse(runAt) : Number.NaN }
})
.sort((a, b) => {
const aTime = a.runAt ? Date.parse(a.runAt) : Number.NaN
const bTime = b.runAt ? Date.parse(b.runAt) : Number.NaN
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
if (Number.isFinite(a.time) && Number.isFinite(b.time)) {
return b.time - a.time
}
return b.id.localeCompare(a.id)
return b.run.id.localeCompare(a.run.id)
})
.map(({ run }) => run)
}
function hermesScheduleDisplay(job: ExternalJobRecord): string {
@@ -0,0 +1,38 @@
import { expect, it, vi } from 'vitest'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
it.each([mapHermesJobs, mapOpenClawJobs])(
'parses run dates once and preserves provider fallback ordering',
(mapJobs) => {
const runs = Array.from({ length: 2000 }, (_, i) => ({
id: String(i),
run_at:
i % 137 === 0
? 'invalid'
: new Date(1700000000000 + ((i * 173) % 1999) * 1000).toISOString(),
output_content: `Output ${i}`,
status: 'completed'
}))
const parse = vi.spyOn(Date, 'parse')
let expected: typeof runs
let jobs: ReturnType<typeof mapHermesJobs>
try {
expected = [...runs].sort((a, b) => {
const left = Date.parse(a.run_at),
right = Date.parse(b.run_at)
return Number.isFinite(left) && Number.isFinite(right)
? right - left
: b.id.localeCompare(a.id)
})
expect(parse.mock.calls.length).toBeGreaterThan(10_000)
parse.mockClear()
jobs = mapJobs('manager', [{ id: 'job', runs }])
expect(parse).toHaveBeenCalledTimes(2000)
} finally {
parse.mockRestore()
}
expect(jobs[0].runs.map((run) => run.id)).toEqual(expected.map((run) => run.id))
expect(jobs[0].runs.every((run) => run.outputContent === `Output ${run.id}`)).toBe(true)
expect(jobs[0].runs.every((run) => !('time' in run))).toBe(true)
}
)
@@ -1,3 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -56,9 +57,8 @@ export function buildSummary(
}
}
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topModel = highestUsageKey(byModel)
const topProject = highestUsageKey(byProject)
return {
scope,
@@ -0,0 +1,53 @@
import { expect, it, vi } from 'vitest'
import { attributeClaudeUsageTurns } from './worktree-attribution'
import type { ClaudeUsageParsedTurn } from './types'
vi.mock('node:fs/promises', () => ({ realpath: async (path: string) => path }))
it('resolves repeated nested and unmatched cwd paths once per attribution batch', async () => {
const lookup = new Map(
Array.from({ length: 100 }, (_, index) => [
`/repo-${String(index).padStart(3, '0')}`,
{
repoId: `repo-${index}`,
worktreeId: `wt-${index}`,
path: `/repo-${index}`,
displayName: `Repo ${index}`
}
])
)
const input: ClaudeUsageParsedTurn[] = Array.from({ length: 1000 }, (_, index) => ({
sessionId: String(index),
timestamp: '2026-09-07T00:00:00Z',
model: null,
cwd: index % 2 === 0 ? '/repo-099/nested' : '/outside',
gitBranch: null,
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheWrite1hTokens: 0
}))
const original = String.prototype.startsWith
let comparisons = 0
const spy = vi.spyOn(String.prototype, 'startsWith').mockImplementation(function (
this: string,
search: string,
position?: number
) {
if (search.slice(0, 6) === '/repo-') {
comparisons += 1
}
return original.call(this, search, position)
})
let result: Awaited<ReturnType<typeof attributeClaudeUsageTurns>>
try {
result = await attributeClaudeUsageTurns(input, lookup)
} finally {
spy.mockRestore()
}
expect(comparisons).toBeLessThanOrEqual(200)
expect(result![0].worktreeId).toBe('wt-99')
expect(result![1].worktreeId).toBeNull()
expect(result![1].projectKey).toBe('cwd:/outside')
})
@@ -105,7 +105,7 @@ export async function attributeClaudeUsageTurns(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsageAttributedTurn[]> {
const attributed: ClaudeUsageAttributedTurn[] = []
const canonicalCwdByPath = new Map<string, string>()
const worktreeByCwd = new Map<string, ClaudeUsageWorktreeRef | null>()
for (const turn of turns) {
const day = localDayFromTimestamp(turn.timestamp)
@@ -119,14 +119,12 @@ export async function attributeClaudeUsageTurns(
let projectLabel = getDefaultProjectLabel(turn.cwd)
if (turn.cwd) {
let canonicalCwd = canonicalCwdByPath.get(turn.cwd)
if (canonicalCwd === undefined) {
// Why: Claude transcripts repeat the same cwd for many consecutive
// turns. Cache realpath work so attribution scales with unique paths.
canonicalCwd = await canonicalizePath(turn.cwd)
canonicalCwdByPath.set(turn.cwd, canonicalCwd)
let worktree = worktreeByCwd.get(turn.cwd)
if (worktree === undefined) {
const canonicalCwd = await canonicalizePath(turn.cwd)
worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
worktreeByCwd.set(turn.cwd, worktree)
}
const worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
@@ -38,6 +38,7 @@ export type ClaudeStructuredSdkOptions = Pick<
| 'sessionId'
| 'resume'
| 'resumeSessionAt'
| 'resumeDropsTurn'
>
/**
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest'
import {
adapterFor,
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
import { ClaudeRewindAttempt } from './claude-structured-rewind'
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
const intent = { targetUuid: 'kept', previousLeafUuid: 'tip', dropsTurn: 'drop' }
const proofLaunch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
describe('Claude rewind acquisition', () => {
it('executes a cursor resume in place and proves the exact target before publication', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
try {
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: intent
})
expect(acquired.link.handle).toMatchObject({
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
leafUuid: 'kept'
})
expect(fake.connections[0]!.launch.options).toMatchObject({
resume: PROVIDER_SESSION_ID,
resumeSessionAt: 'kept',
resumeDropsTurn: 'drop'
})
expect(fake.connections[0]!.launch.options).not.toHaveProperty('forkSession')
expect(proof).toHaveBeenCalledWith(
expect.objectContaining({ previousLeafUuid: 'tip', intentionalRewindUuid: 'kept' })
)
await adapter.closeSession('session-1')
await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-next' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(
proof.mock.calls.filter(([input]) => input.intentionalRewindUuid !== undefined)
).toHaveLength(1)
} finally {
await adapter.closeAll()
}
})
it('recognizes the documented refusal and closes the failed child without retry', async () => {
const fake = fakeClaude()
const openConnection = fake.openConnection
fake.openConnection = async (launch, handlers) => {
const connection = await openConnection(launch, handlers)
const initialize = connection.initializationResult
connection.initializationResult = async (...args) => {
const result = await initialize(...args)
handlers?.onMessage?.({
type: 'result',
subtype: 'error_during_execution',
session_id: PROVIDER_SESSION_ID,
errors: ['Resume rejected by --resume-drops-turn: additional prompt observed']
})
return result
}
return connection
}
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(fake, { resumed: true }, [], [], undefined, proof)
await expect(
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn', rewind: intent })
).rejects.toMatchObject({ rewindReason: 'provider-refused' })
expect(fake.connections).toHaveLength(1)
expect(fake.connections[0]?.closed).toBe(true)
expect(proof).not.toHaveBeenCalled()
await adapter.closeAll()
})
it('consumes proof authorization even if its first read fails', async () => {
const proof = vi.fn(async () => {
throw new Error('torn transcript')
})
const attempt = new ClaudeRewindAttempt(intent)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBeInstanceOf(
AgentSessionRewindRefusal
)
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('never persists success for a mismatching leaf', async () => {
const onProved = vi.fn(async () => {})
const attempt = new ClaudeRewindAttempt(intent, onProved)
await expect(
attempt.prove(proofLaunch, { readTranscriptLeaf: async () => 'other' })
).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(onProved).not.toHaveBeenCalled()
})
it('preserves commit failure as unknown and consumes the override before persisting', async () => {
const diskError = new Error('record write failed')
const onProved = vi.fn(async () => {
throw diskError
})
const proof = vi.fn(async () => 'kept')
const attempt = new ClaudeRewindAttempt(intent, onProved)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBe(diskError)
expect(onProved).toHaveBeenCalledWith('kept')
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('checkpoints the proved target before late acquisition failure without persisting a stale cursor', async () => {
const fake = fakeClaude()
const launch = { resumed: true, resumeLeafUuid: 'tip' }
const persisted: unknown[] = []
const proof = vi.fn(async () => 'kept')
const adapter = adapterFor(fake, launch, [], persisted, undefined, proof)
const onProved = vi.fn(async (leafUuid: string) => {
launch.resumeLeafUuid = leafUuid
fake.connections[0]!.closed = true
})
try {
await expect(
adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: { ...intent, onProved }
})
).rejects.toThrow('exited while being acquired')
expect(onProved).toHaveBeenCalledWith('kept')
expect(persisted).toEqual([])
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 8,
spawnToken: 'retry'
})
expect(acquired.link.handle).toMatchObject({ leafUuid: 'kept' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(proof).toHaveBeenCalledTimes(1)
} finally {
await adapter.closeAll()
}
})
it('restores an interrupted unproved rewind only after exact ordinary branch proof', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const restored = vi.fn(async () => {})
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
const input = {
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewindRecovery: { leafUuid: 'tip', onProved: restored }
}
try {
await expect(adapter.acquire(input)).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(restored).not.toHaveBeenCalled()
proof.mockResolvedValue('tip')
await adapter.acquire({ ...input, fence: 8, spawnToken: 'retry' })
expect(restored).toHaveBeenCalledOnce()
expect(proof).toHaveBeenCalledWith(expect.objectContaining({ previousLeafUuid: 'tip' }))
for (const [request] of proof.mock.calls) {
expect(request).not.toHaveProperty('intentionalRewindUuid')
}
} finally {
await adapter.closeAll()
}
})
})
+118
View File
@@ -0,0 +1,118 @@
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export function claudeRewindRefusalFromMessage(
message: Record<string, unknown>
): AgentSessionRewindRefusal | null {
return message.type === 'result' &&
message.subtype === 'error_during_execution' &&
Array.isArray(message.errors) &&
message.errors.some(
(error) =>
typeof error === 'string' && error.startsWith('Resume rejected by --resume-drops-turn:')
)
? new AgentSessionRewindRefusal('provider-refused')
: null
}
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state'
type Intent = NonNullable<StructuredAgentSessionAcquireInput['rewind']>
/** The proof authorization exists only for this acquisition's first proof attempt. */
export class ClaudeRewindAttempt {
private refusal: AgentSessionRewindRefusal | null = null
constructor(
private intent: Intent | undefined,
private readonly onProved?: (leafUuid: string) => Promise<void>
) {}
observe(message: Record<string, unknown>): AgentSessionRewindRefusal | null {
if (!this.intent) {
return null
}
this.refusal ??= claudeRewindRefusalFromMessage(message)
return this.refusal
}
applyLaunch(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): void {
if (!this.intent) {
return
}
if (!launch.resumed || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('unsupported')
}
launch.options = {
...launch.options,
resume: launch.providerSessionId,
resumeSessionAt: this.intent.targetUuid,
...(this.intent.dropsTurn ? { resumeDropsTurn: this.intent.dropsTurn } : {})
}
launch.resumeLeafUuid = this.intent.targetUuid
}
async prove(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
const intent = this.intent
this.clear()
if (this.refusal) {
throw this.refusal
}
if (!intent) {
return null
}
let leaf: string | null
try {
leaf = await deps.readTranscriptLeaf!({
providerSessionId: launch.providerSessionId,
previousLeafUuid: intent.previousLeafUuid,
intentionalRewindUuid: intent.targetUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== intent.targetUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
} catch (error) {
throw error instanceof AgentSessionRewindRefusal
? error
: new AgentSessionRewindRefusal('proof-mismatch')
}
// Persistence failure is an unknown outcome, never evidence that the provider refused.
await this.onProved?.(leaf)
return leaf
}
clear(): void {
this.intent = undefined
}
}
/** An interrupted, unproved rewind restores its original cursor without ancestor authorization. */
export async function proveClaudeRewindRecovery(
recovery: StructuredAgentSessionAcquireInput['rewindRecovery'],
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
if (!recovery) {
return null
}
if (!launch.resumed || launch.resumeLeafUuid !== recovery.leafUuid || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
const leaf = await deps.readTranscriptLeaf({
providerSessionId: launch.providerSessionId,
previousLeafUuid: recovery.leafUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== recovery.leafUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
await recovery.onProved()
return leaf
}
@@ -1,3 +1,4 @@
import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionPreSpawnError
@@ -85,6 +86,7 @@ export async function acquireClaudeSession({
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
@@ -95,6 +97,11 @@ export async function acquireClaudeSession({
}
return
}
const refusal = rewind.observe(message)
if (refusal) {
initDeadline.reject(refusal)
return
}
if (init) {
initDeadline.resolve(init)
// Every turn opens with an init frame naming the model the CLI is actually
@@ -178,6 +185,7 @@ export async function acquireClaudeSession({
? error
: new AgentSessionPreSpawnError(error)
})
rewind.applyLaunch(launch, deps)
expectedProviderSessionId = launch.providerSessionId
observedLeafUuid = launch.resumeLeafUuid
acquisitions.assertCurrent(sessionId, attempt)
@@ -241,6 +249,9 @@ export async function acquireClaudeSession({
diagnostic: claudeAuthDiagnostic(init, settings)
})
)
observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid
observedLeafUuid =
(await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid
const process = await claudeProcessIdentity(
{ ...input, pid: connection.pid },
deps.readProcessStartTime
@@ -298,6 +309,7 @@ export async function acquireClaudeSession({
acquisitions.deleteIfCurrent(sessionId, attempt)
throw acquisitionError
} finally {
rewind.clear()
attempt.finish()
}
}
@@ -4,7 +4,6 @@ import type {
StructuredAgentSessionAcquireInput,
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
answerClaudePrompt,
cancelClaudeTurn,
@@ -58,6 +57,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
supportsLocation = supportsClaudeStructuredLocation
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = () =>
this.deps.readTranscriptLeaf ? { supported: true } : { supported: false, reason: 'unsupported' }
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireClaudeSession({
input,
@@ -67,7 +69,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
exits: this.exits,
callbacks: {
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
emit: (session, events, event) => this.emit(session, events, event),
emit: (session, _events, event) => this.emit(session, event),
handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error),
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
}
@@ -155,7 +157,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
acquisitionGeneration: exit.session.acquisitionGeneration
}
try {
this.emit(exit.session, exit.session.events, ended)
this.emit(exit.session, ended)
} finally {
settleClaudeExitedSession(exit.session)
}
@@ -187,11 +189,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
})
}
private emit(
session: ClaudeSession | null,
_events: StructuredAgentSessionEventSink | undefined,
event: ClaudeStructuredSessionEvent
): void {
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
const backgroundTasksChanged =
event.type === 'ended'
? (session?.backgroundTasks.clear() ?? false)
@@ -88,6 +88,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
readTranscriptLeaf?: (input: {
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
/** Account-scoped Claude config root that owns this provider session. */
claudeConfigDir: string
}) => Promise<string | null>
@@ -13,7 +13,7 @@ type TranscriptNode = {
export type ClaudeTranscriptBranchProof = {
leafUuid: string
relation: 'initial' | 'same' | 'descendant'
relation: 'initial' | 'same' | 'descendant' | 'intentional-rewind'
}
function nonEmptyString(value: unknown): string | null {
@@ -83,6 +83,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
contents: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): ClaudeTranscriptBranchProof {
const nodes = new Map<string, TranscriptNode>()
let leafUuid: string | null = null
@@ -156,6 +157,21 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
throw transcriptError('marker precedes its leaf record')
}
const previousLeafUuid = input.previousLeafUuid
if (input.intentionalRewindUuid !== undefined) {
if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
throw transcriptError('rewind target does not match the observed leaf')
}
proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
proveAppendOrder(nodes)
let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
if (ancestor === leafUuid) {
return { leafUuid, relation: 'intentional-rewind' }
}
ancestor = nodes.get(ancestor)?.parentUuid ?? null
}
throw transcriptError('rewind target is not an ancestor of the previous cursor')
}
if (!previousLeafUuid) {
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
// A branch proof is based on an append-only snapshot. A child that appears
@@ -210,11 +226,13 @@ export async function proveClaudeTranscriptBranch(input: {
transcriptPath: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): Promise<ClaudeTranscriptBranchProof> {
return proveClaudeTranscriptBranchFromJsonl({
contents: await readFile(input.transcriptPath, 'utf8'),
providerSessionId: input.providerSessionId,
previousLeafUuid: input.previousLeafUuid
previousLeafUuid: input.previousLeafUuid,
intentionalRewindUuid: input.intentionalRewindUuid
})
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof'
const row = (uuid: string, parentUuid: string | null, extra = {}) =>
JSON.stringify({ type: 'assistant', sessionId: 'provider', uuid, parentUuid, ...extra })
const marker = (leafUuid: string) =>
JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid })
const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')]
const prove = (rows: string[], leaf: string, intentionalRewindUuid?: string) =>
proveClaudeTranscriptBranchFromJsonl({
contents: `${[...rows, marker(leaf)].join('\n')}\n`,
providerSessionId: 'provider',
previousLeafUuid: 'old',
intentionalRewindUuid
})
describe('explicit Claude rewind ancestry', () => {
it('admits only the exact requested main-chain ancestor', () => {
expect(prove(graph, 'kept', 'kept')).toEqual({
leafUuid: 'kept',
relation: 'intentional-rewind'
})
expect(() => prove(graph, 'kept')).toThrow('sibling')
expect(() => prove(graph, 'kept', 'root')).toThrow('target')
expect(() => prove(graph, 'old', 'old')).toThrow('not an ancestor')
})
it('keeps sibling and sidechain rejection even with explicit intent', () => {
expect(() => prove([...graph, row('sibling', 'root')], 'sibling', 'sibling')).toThrow(
'not an ancestor'
)
expect(() =>
prove(
[row('root', null), row('kept', 'root', { isSidechain: true }), row('old', 'kept')],
'kept',
'kept'
)
).toThrow()
})
it('refuses missing, reordered, or cyclic ancestry', () => {
expect(() => prove(graph.slice(1), 'kept', 'kept')).toThrow('missing ancestor')
expect(() => prove([graph[1]!, graph[0]!, graph[2]!], 'kept', 'kept')).toThrow(
'parent row follows'
)
expect(() => prove([row('root', 'old'), ...graph.slice(1)], 'kept', 'kept')).toThrow('cycle')
})
})
@@ -1,3 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@@ -57,9 +58,8 @@ export function buildSummary(
}
}
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topModel = highestUsageKey(byModel)
const topProject = highestUsageKey(byProject)
return {
scope,
@@ -0,0 +1,61 @@
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import { buildImageDataUri } from '../../shared/image-data-uri'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds'
import { readString } from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-thread-item-identity'
// Leave room for operation text and the journal envelope beside inline image bytes.
const MAX_IMAGE_REFERENCE_BYTES = DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes / 2
function imagePath(item: CodexThreadItem, key: string): string | null {
const value = readString(item, key)
return value?.trim() && Buffer.byteLength(value, 'utf8') <= MAX_IMAGE_REFERENCE_BYTES
? value
: null
}
function generatedImageUrl(item: CodexThreadItem): string | null {
const result = readString(item, 'result')
if (!result || result.length > MAX_IMAGE_REFERENCE_BYTES) {
return null
}
const match = /^data:(image\/(?:png|jpeg|webp));base64,(.*)$/s.exec(result)
const base64 = (match?.[2] ?? result).replace(/\s/g, '')
if (!base64 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) {
return null
}
const url = buildImageDataUri(match?.[1] ?? 'image/png', base64)
return url && url.length <= MAX_IMAGE_REFERENCE_BYTES ? url : null
}
export function codexImageItemBody(item: CodexThreadItem): AgentJournalMessageItem {
let image: Extract<NativeChatBlock, { type: 'image-ref' }> | null = null
let text: string
if (item.type === 'imageView') {
const path = imagePath(item, 'path')
text = path ? 'Viewed image' : 'Image view: preview unavailable'
image = path ? { type: 'image-ref', path } : null
} else {
const status = readString(item, 'status')
if (item.failure || status === 'failed') {
text = 'Image generation failed'
} else if (status !== 'completed') {
text = status === 'inProgress' ? 'Generating image…' : 'Image generation: preview unavailable'
} else {
const path = imagePath(item, 'savedPath')
const url = path ? null : generatedImageUrl(item)
image = path
? { type: 'image-ref', path }
: url
? { type: 'image-ref', url, alt: 'Generated image' }
: null
text = image ? 'Generated image' : 'Image generated: preview unavailable'
}
}
return {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text }, ...(image ? [image] : [])]
}
}

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