Merge remote-tracking branch 'origin/main' into brennanb2025/fix-subagent-phantom-blocks-hibernation

This commit is contained in:
Brennan Benson
2026-07-29 15:08:04 -07:00
764 changed files with 57881 additions and 9236 deletions
+10 -1
View File
@@ -184,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
timeout-minutes: 35
steps:
- name: Checkout
@@ -227,6 +229,13 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
+1
View File
@@ -128,6 +128,7 @@ jobs:
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--require-distinct-artifacts `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
+1
View File
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+7 -1
View File
@@ -83,12 +83,17 @@ module.exports = {
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!tests{,/**/*}',
// Why: examples/ is plugin authoring documentation with no runtime consumer —
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
// carries hostile-panel, the adversarial fixture the containment tests point at,
// which must never reach a user's install.
'!examples{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
@@ -148,6 +153,7 @@ module.exports = {
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
+20
View File
@@ -13,6 +13,26 @@
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!include "getProcessInfo.nsh"
Var pid
!macro customCheckAppRunning
${if} ${isUpdated}
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
Pop $0
${if} $0 != 0
DetailPrint "Orca update release proof failed with status $0."
SetErrorLevel 2
Quit
${endIf}
${else}
!insertmacro IS_POWERSHELL_AVAILABLE
!insertmacro _CHECK_APP_RUNNING
${endIf}
!macroend
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
+198 -19
View File
@@ -16,6 +16,103 @@
}
},
"gates": [
{
"id": "mobile-ui.drawer-close-continuity",
"title": "Mobile drawers finish closing despite parent rerenders",
"maturity": "experimental",
"protection": "partial",
"owner": "mobile-ui",
"layer": "react-native-modal-lifecycle",
"surfaces": [
"host action sheet",
"host rename navigation",
"host removal confirmation",
"shared mobile bottom drawers"
],
"platforms": [
"ios",
"android",
"macos"
],
"providers": [
"provider-independent"
],
"coveredPlatforms": [
"ios",
"macos"
],
"coveredProviders": [
"provider-independent"
],
"coverageNotes": "A deterministic React lifecycle test proves callback churn cannot restart an in-flight drawer close, and an iOS 26.5 simulator run covers Edit Host plus confirmed host removal. Android native-modal behavior remains a live-test gap.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8791"
],
"invariant": "Once a bottom drawer begins closing, unrelated parent rerenders must not replace its completion callback or restart the native hide animation. The drawer must unmount once and deliver the latest after-close action exactly once.",
"oracle": "Render one drawer, begin closing it, rerender with new parent callbacks before completion, and require every MountedBottomDrawer frame to retain one onHidden identity. Trigger that completion barrier repeatedly, then require the drawer's null render to commit before only the latest after-close callback runs exactly once.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
"Manual iOS 26.5 simulator: long-press paired host; open Edit host; return; long-press host; Remove; confirm Remove; assert host disappears"
],
"testFiles": [
"mobile/src/components/bottom-drawer-close-lifecycle.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
"assertions": [
"keeps close stable and delivers the latest action once after unmount"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/components/bottom-drawer-close-lifecycle.test.ts",
"result": "passed",
"durationSeconds": 0.18,
"summary": "The focused lifecycle harness passed with stable completion identity, latest-callback delivery, and drawer unmount assertions."
},
{
"date": "2026-07-28",
"runner": "manual",
"platform": "ios",
"command": "Manual iOS 26.5 simulator: long-press paired host; open Edit host; return; long-press host; Remove; confirm Remove; assert host disappears",
"result": "passed",
"durationSeconds": 37,
"summary": "Edit host opened responsively after the drawer closed; returning and confirming Remove deleted the host without freezing."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused React lifecycle contract test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One deterministic local contract run and one iOS simulator flow exist; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On current main, the harness failed because each parent render created a different onHidden callback. The stable completion callback fix passes the byte-identical oracle; restoring the inline callback reproduces the failure."
},
"performanceBudget": {
"required": true,
"evidence": "Any number of parent rerenders retains one close-completion identity, so they add zero hide-animation restarts, timers, listeners, or after-close deliveries."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
"Run the host Edit and Remove flows on a physical iOS device and an Android emulator or device.",
"Keep the callback-identity and exactly-once delivery assertions intact for every shared drawer lifecycle change."
],
"knownGaps": [
"Android native-modal behavior has no live evidence.",
"The simulator run used an unreachable stored host rather than a connected multi-worktree host.",
"The contract test injects the hide-completion barrier instead of running Reanimated."
],
"demotionRule": "Keep experimental or demote if parent rerenders can restart drawer hiding, after-close delivery duplicates or goes stale, the focused contract flakes, or either mobile platform retains a touch-blocking modal."
},
{
"id": "mobile-relay.endpoint-recovery",
"title": "Mobile relay recovery retries offline hosts and races direct endpoints",
@@ -5135,7 +5232,7 @@
},
{
"id": "orchestration.worker-terminal-delivery",
"title": "Started workers are visible without stealing focus and retain mailbox identity",
"title": "Workers remain visible and observable across workspace entry and app restart",
"maturity": "experimental",
"protection": "partial",
"owner": "orchestration",
@@ -5145,7 +5242,9 @@
"worker-start",
"terminal creation",
"terminal tab materialization",
"workspace re-entry"
"workspace re-entry",
"app restart with retained daemon PTYs",
"legacy update resume fencing"
],
"platforms": [
"macos",
@@ -5167,25 +5266,58 @@
"daemon",
"ssh"
],
"coverageNotes": "Deterministic units cover local worker presentation, reveal-failure warnings, stable-pane Run/Dispatch routing, and the SSH in-process CLI fallback. An isolated macOS Electron journey launches a fake Codex worker through the real RPC path, tolerates spawn-time handle reminting, asserts the inactive tab in the DOM before navigation, checks Run delivery by pane identity, and proves workspace re-entry keeps one worker tab by both original tab ID and visible title. SSH, WSL, remote-runtime, Linux, and Windows remain live-test gaps; federated workers retain explicit background presentation.",
"coverageNotes": "Deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, federated non-reveal, and fail-closed distinct Windows package proof. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"
],
"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. 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, 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 a production Electron runtime with a deterministic Codex fixture. Require the worker tab to be visible in the DOM with data-active=false while the coordinator tab stays active, send ACK to the Run, read it with a deliberately stale coordinator handle plus its stable pane key, switch workspaces away and back, and require exactly one worker tab by original ID and visible title. Unit tests separately assert local worker-start omits background presentation, reveal failures return an actionable warning without discarding the worker, reminted coordinators and workers retain mailbox routing, live handles outrank mismatched pane metadata, and explicit legacy terminal checks do not inherit the caller pane key locally or through the SSH fallback.",
"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. 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, 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. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, SSH fallback parity, and distinct packaged installer paths, versions, and hashes.",
"commands": [
"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.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1"
],
"testFiles": [
"src/main/runtime/orchestration/formatter.test.ts",
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"src/main/runtime/orca-runtime.test.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.test.ts",
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"src/main/ssh/ssh-remote-orca-cli.test.ts",
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts"
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
"tools/win-update-e2e/installer-pair-proof.test.mjs"
],
"assertionRefs": [
{
"file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"assertions": [
"only exact unique terminal, pane, process-incarnation, and worktree evidence becomes recoverable",
"ambiguous, incomplete, or mismatched legacy identities remain fenced and deferred"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"one exact live legacy worker is adopted into its original background pane without input or signals",
"automatic provider resume stays fenced until exact adoption is persisted",
"retained renderer output remains readable through the recovered terminal"
]
},
{
"file": "src/main/runtime/orchestration/formatter.test.ts",
"assertions": [
"legacy compatibility and recovery replay show only runtime-supplied supported actions",
"legacy provenance without live authority stays read-only",
"current formatting remains unchanged"
]
},
{
"file": "src/cli/handlers/orchestration-check-identity.test.ts",
"assertions": [
@@ -5209,6 +5341,12 @@
"a live handle cannot be retargeted by mismatched pane metadata"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"assertions": [
"federated worker placement explicitly sets activate=false"
]
},
{
"file": "src/main/ssh/ssh-remote-orca-cli.test.ts",
"assertions": [
@@ -5222,7 +5360,25 @@
"worker-start exposes one inactive worker tab before workspace navigation",
"the coordinator tab remains active",
"ACK delivery reaches a stable coordinator pane through a stale handle",
"workspace re-entry does not duplicate the worker tab under the same or a new tab ID"
"one spawn remains live with no interruption event",
"PTY/incarnation, tab, leaf, worktree, Task, and Dispatch identities remain stable",
"workspace re-entry does not duplicate the worker tab or print Conversation interrupted"
]
},
{
"file": "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
"assertions": [
"the original daemon PTY, process incarnation, PID, pane, Task, and Dispatch survive app restart",
"the legacy worker tab is restored once in the background with retained ACK output",
"sleeping-session, resume-claim, and pending-startup state are cleared after adoption",
"no second spawn, resume argv, input, signal, interruption, or duplicate tab occurs after workspace re-entry"
]
},
{
"file": "tools/win-update-e2e/installer-pair-proof.test.mjs",
"assertions": [
"distinct package proof records different paths, versions, and SHA-256 hashes",
"identical paths, bytes, versions, or unreadable versions fail closed"
]
}
],
@@ -5236,43 +5392,66 @@
"durationSeconds": 5.27,
"summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts tools/win-update-e2e/installer-pair-proof.test.mjs",
"result": "passed",
"durationSeconds": 2.72,
"summary": "Three focused files passed with 39 tests, covering authority-aware legacy affordances, federated non-reveal, and fail-closed distinct packaged installer proof."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 11.5,
"summary": "The isolated Electron journey passed with a real Run and Task, deterministic Codex PTY, immediate inactive DOM tab, ACK delivery through a stale coordinator handle, and exactly one worker tab by original ID and management title after workspace re-entry."
"durationSeconds": 8.3,
"summary": "The isolated Electron journey passed with one live fake-agent spawn, no interruption events, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, immediate inactive presentation, pane-stable ACK delivery, and exactly one tab after workspace re-entry."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 12.6,
"summary": "The restart journey retained one daemon PTY and PID, adopted its exact original background pane, preserved readable ACK output and Task/Dispatch authority, cleared all resume state, and recorded no second spawn, resume argv, input, signal, interruption, or duplicate after workspace re-entry."
}
],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "focused CLI/runtime units plus one isolated Electron worker-start journey"
"p95Seconds": 75,
"scope": "focused CLI/runtime units plus isolated worker-start and app-restart Electron journeys"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic units and isolated Electron journey pass locally; CI and soak history are not yet available."
"evidence": "The deterministic units and two isolated Electron journeys pass locally; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation, and the live incident plus Electron topology showed the worker PTY existed without an immediate tab. Saved intentional-break and CI artifacts are still needed."
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation. The restart journey additionally failed first on empty retained output while the original PTY/PID remained live, then passed after the scoped recovered-worker snapshot fallback. The live incident and pre-fix Electron topology showed workspace re-entry replaying provider resume against a worker whose tab binding was missing. Saved intentional-break and CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup; it adds no extra RPC, subprocess, timer, or renderer update. Federated and explicitly background terminals are unchanged."
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Startup recovery performs one bounded controller inventory per legacy candidate, exact owner/identity checks, one background reveal, and a provider/renderer snapshot only when an adopted worker's in-memory tail is empty and terminal.read is explicitly requested. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup. Federated and explicitly background terminals are unchanged."
},
"promotionCriteria": [
"Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.",
"Add live SSH and WSL exact-workspace worker-start evidence and a paired remote-runtime control.",
"Run distinct installed A/B artifacts through separate headed and headless paired-runtime cutover journeys, plus a git-independent folder workspace parameter.",
"Add Docker SSH restart/reconnect proof and physical Windows WSL correct-distro/wrong-distro proof.",
"Gate a packaged Windows real-updater journey with a blocked packaged CLI ask, launcher status 75, exact native/WSL resume commands, and one question/reply.",
"Attach saved intentional-break artifacts for hidden local presentation and dropped stable-pane delivery."
],
"knownGaps": [
"No live Linux, Windows, SSH, WSL, or paired remote-runtime run is attached.",
"No distinct installed A/B headed or headless paired-runtime cutover is attached; the restart journey relaunches the same build while preserving the daemon and agent.",
"No Docker SSH restart/reconnect, physical Windows WSL distro-authority, or git-independent folder-workspace cutover journey is attached.",
"The Windows workflow now rejects non-distinct package inputs, but it does not yet drive the packaged updater with a blocked packaged CLI ask or prove status 75 and native/WSL resume.",
"The Electron journey uses a deterministic fake Codex CLI rather than a real account.",
"The gate does not cover terminal output recovery after app restart; it covers identity, discoverability, focus, and mailbox routing."
"The local presentation journey tolerates its existing terminal-handle remint and proves continuity by PTY/incarnation/tab/leaf; byte-stable handle proof across a real A/B cutover awaits the runtime-authority/RPC implementation.",
"The local Electron restart proof covers retained visible output from the same-build daemon checkpoint, not transcript recovery after a transport cut or distinct A/B runtime replacement."
],
"demotionRule": "Keep experimental or demote if the Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if workspace re-entry duplicates the tab, or if pane-stable delivery reads the wrong mailbox."
"demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox."
},
{
"id": "terminal-render.windows-cjk-repaint",
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
@@ -38,15 +39,39 @@ describe('electron-builder config', () => {
'!skill-stubs{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
it('keeps plugin authoring examples out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
// copyFiles() prepends this itself once the pattern list is all-negation.
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const authoringOnly of [
'examples/plugins/hostile-panel/panel.html',
'examples/plugins/hostile-panel/orca-plugin.json',
'examples/plugins/hello-orca/main.mjs',
'examples/plugins/hello-orca/orca-plugin.json'
]) {
expect(packs(authoringOnly)).toBe(false)
}
// The negation stays anchored at the app root, so nested `examples` segments still ship.
expect(packs('out/main/examples/index.js')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
@@ -141,6 +166,14 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
@@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => {
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
expect(devRunner).toContain("'Electron Helper.app',")
expect(devRunner).toContain(
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
)
})
})
@@ -1,5 +1,6 @@
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
export function mergeLocaleKeyOverrides(base) {
const merged = { ...base }
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
// KO split overrides can share keys with zh/ja repairs; merge per locale.
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
return merged
}
@@ -2231,9 +2231,6 @@
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
"ko": "자동화"
},
@@ -0,0 +1,18 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
zh: '完全磁盘访问权限'
}
}
@@ -0,0 +1,19 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -1,4 +1,5 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES = ['es', 'ja', 'ko', 'zh']
const KEYS = [
'auto.hooks.useMacosTccPromptNotice.title',
'auto.hooks.useMacosTccPromptNotice.description',
'auto.hooks.useMacosTccPromptNotice.openSettings',
'auto.hooks.useMacosTccPromptNotice.dismiss',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
]
function readCatalog(locale) {
return JSON.parse(
fs.readFileSync(
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
'utf8'
)
)
}
function getValue(catalog, key) {
return key.split('.').reduce((value, part) => value[part], catalog)
}
describe('macOS TCC prompt localization', () => {
it('survives the canonical catalog repair policy', () => {
const english = readCatalog('en')
for (const locale of LOCALES) {
const catalog = readCatalog(locale)
for (const key of KEYS) {
const enValue = getValue(english, key)
const localeValue = getValue(catalog, key)
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
}
}
})
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
expect(
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('전체 디스크 접근 권한')
expect(
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('完全磁盘访问权限')
})
})
@@ -51,30 +51,39 @@ describe('orchestration skill guidance', () => {
)
})
it('teaches the hard cutover without reviving a legacy executor', () => {
it('teaches attested adoption without reviving the retired scheduler', () => {
const skill = readSkill()
const migration = getSection(skill, 'Contract Migration')
expect(migration).toContain('hard cutover')
expect(migration).toContain('effectsApplied')
expect(migration).toContain('skills get orchestration --full')
expect(migration).toContain('Do not retry the rejected command unchanged')
expect(migration).toContain('no longer supervised')
expect(migration).toContain('task-list --run run_legacy_local')
expect(migration).toContain('Read-only inspection never consumes legacy mail')
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
expect(migration).toContain('does not cancel the prior assignment')
expect(migration).toContain('invalidate its worktree')
expect(migration).toContain('discard filesystem changes')
expect(migration).toContain('leave it as the only editor in that worktree')
expect(migration).toContain('observe it manually, read-only')
expect(migration).toContain('until it reaches a stable handoff point')
expect(migration).toContain('visible activity is a reason to keep observing')
expect(migration).toContain(
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
'adopts a live pre-update orchestration assignment into an ordinary Run'
)
expect(migration).toContain('if remaining work needs new lifecycle supervision')
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
expect(migration).toContain(
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
)
expect(migration).toContain('never restarts or replaces the worker')
expect(migration).toContain('The retired scheduler is not revived')
expect(migration).toContain('[LEGACY COMPATIBILITY]')
expect(migration).toContain('[LEGACY READ-ONLY]')
expect(migration).toContain(
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
)
expect(migration).toContain(
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
)
expect(migration).not.toContain('task-list --run run_legacy_local')
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
expect(migration).toContain('Recovered orchestration work from a contract update')
expect(migration).toContain('run-show --id <adopted_run_id>')
expect(migration).toContain('task-list --run <adopted_run_id>')
expect(migration).toContain('Legacy inspection remains available without consuming mail')
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
expect(migration).toContain('Takeover fences only the old coordinator')
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
expect(migration).toContain(
'keep the original worker as the only editor until it reaches a stable handoff point'
)
expect(migration).toContain('a conflict-free placement for any remaining work')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
+12 -4
View File
@@ -128,10 +128,8 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
// v7: give the terminal daemon helper an Orca-specific TCC identity.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -155,6 +153,7 @@ function prepareMacDevElectronApp() {
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
const helperBundleId = `${bundleId}.helper`
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -205,9 +204,18 @@ function prepareMacDevElectronApp() {
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
const helperPlistPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Helper.app',
'Contents',
'Info.plist'
)
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why both specs in one runner: they share the docker SSH rig and the same
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
// restore, the retention spec proves the C1 budget bounds what they retain.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-terminal-parking.spec.ts',
'tests/e2e/terminal-retention-budget.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
+1
View File
@@ -4,6 +4,7 @@
"../electron.vite.config.*",
"../build-plugins/**/*",
"../src/main/**/*",
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/relay/**/*",
@@ -1,213 +0,0 @@
# STA-2694 — garbled terminal after switching away from an AI workspace
Handoff snapshot: 2026-07-27, branch
`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`.
## The report
> When I use an AI tool—such as OpenCode—and switch away to the desktop or
> another task, the page appears garbled or distorted upon returning; I have to
> resize the window to restore the display.
Reporter hints: related to terminal parking / workspace switching; also happens
with Claude Code and probably grok.
## Status
**One defect, root-caused and fixed, with a mechanism-level test that fails
without the fix.** The garble window is *unbounded*, not the ~1s I originally
claimed — see [the correction](#correction-the-window-is-unbounded).
A second hypothesis was implemented, then **refuted by measurement and
reverted**. That refutation is the other half of the value here: it rules out a
whole class of "just force a fuller repaint" fixes, and it is pinned by a test so
it cannot be reintroduced.
| | |
|---|---|
| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal |
| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` |
| Oracle + scope correction | `3ccffc17ec` |
| Unit tests | 8 passing across 2 files |
| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful |
| Teeth-verified | Yes — removing the fix fails the stranded-latch test |
## The defect
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in
DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second.
Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion
lands there routinely) and xterm keeps
`decPrivateModes.synchronizedOutput` latched `true`.
`RenderService.refreshRows` checks that latch **before** rendering
(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op:
the forced render-pause repaint, the plain `refresh()` fallback, and the shared
glyph-atlas rebuild all render zero rows against a perfectly correct buffer.
The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes
the handler's buffered row range at both reveal repaint entry points, before the
repaint. Both reveal paths reach it:
- `schedulePaneRevealPresent` (plain refocus / desktop return)
- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached
from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path)
### Correction: the window is unbounded
`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the
whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch.
**That was wrong.** The watchdog is armed only inside
`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its
`_isPaused` check *first*:
```ts
public refreshRows(start, end, sync = false, isRedrawOnly = false): void {
if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms
return
}
...
```
While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing
ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden
mid-frame holds the latch with no watchdog behind it, indefinitely — which is the
indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts`
asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing
0 instances.
This also explains the specific workaround: resizing the window makes the TUI
repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch.
## The refuted hypothesis (do not reimplement)
The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells
whose model entry still matches the buffer, leaving an occluded canvas showing
pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading —
`WebglRenderer._updateModel` really does early-continue per unchanged cell.
It is wrong, measured on a live pane:
| Refresh | `updateCell` calls | instances drawn |
|---|---|---|
| diff-skipped (model matches buffer) | 0 | 562 |
| model cleared first | 561 | 562 |
`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]`
into the active buffer and issues **one** full-viewport `drawElementsInstanced`.
The diff only decides how much *vertex data* is rewritten, never how much is
*drawn*. The DOM renderer likewise `replaceChildren()`s every row
unconditionally. So clearing the model cannot change what reaches the screen.
Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and
`RenderService.clear()` fires no repaint of its own — so any paint landing between
the clear and the repopulating refresh draws an **empty** viewport (0 instances,
also asserted in the spec). On a reveal that refresh is debounced through a RAF
and can itself be swallowed, which would turn a merely-stale pane into a blank
one. Reverted in `8d5eacecb4`.
The first test in the oracle spec pins the refutation so this cannot come back as
a "fix".
## Why pixel oracles failed, and what replaced them
The field defect is "the buffer is correct but the compositor shows pre-hide
pixels". Two pixel oracles were built and **both were proven blind** by injecting
that exact defect (freeze `RenderService.refreshRows`, then write new content):
1. **Canvas-vs-buffer ink sampling.** `drawImage` on a
non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it
reported 0 missing cells against 5263 cells of text the canvas had never
drawn.
2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh
compositor frame, which *heals* the stale paint before capture; and the
"repair" ran the same code the reveal already ran, so a shared defect cancels
out.
An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen
rows, so its 4.4% pixel diff measured legitimate reflow.
**Pixels are the wrong layer** — anything that reads them can trigger the repaint
that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts`
instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the
live pane and counts draw commands. A draw command cannot be healed after the
fact, so "did the reveal repaint?" is directly observable — and falsifiable,
which is how the second hypothesis got refuted.
Use that spec for **paint** questions;
`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and
PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop
hide). The idle-agent case matters: every earlier test kept the TUI streaming
across the reveal, so live frames repainted whatever the reveal got wrong and the
defect healed itself before any assertion ran.
## Leads closed by measurement
Both were live suspects in the previous handoff; both are now ruled out.
- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is
behind an unchanged/zero-sized early exit that a hidden pane trips, and
`performSafeFit` early-returns at unchanged geometry — so nothing on the reveal
path re-runs `handleResize()`. Measured across a real `window.hide()` /
`show()`: canvas backing store `1272×1104` matched the renderer's computed
dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true,
the screen element stayed connected. No staleness.
- **Lagging atlas page bindings.** Right after a reveal the bound texture version
*does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and
`GlyphRenderer.render` rebinds any page whose version moved, so one repaint
brings them level (1366/1366). Normal, not a defect. Judge bindings only
*after* a draw.
## Residual risk
The oracle proves the repaint is *issued*; it does not photograph the user's
screen. If a report survives this fix, the remaining suspects are below the draw
call — GPU-process/compositor-level content loss — where the in-app sentinel on
real hardware is the tool:
```js
localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
```
Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts
a 10s burst at 250ms intervals; a trip needs the same cells missing across 2
consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` +
`corrupt.json` to `<userData>/terminal-render-desync-evidence/<captureId>/`, then
runs atlas recovery and captures the healed frame. Armed at import when the flag
is set (`terminal-freeze-breadcrumbs.ts:45`).
## Verification commands
```bash
npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/
npx tsc --noEmit -p config/tsconfig.tc.web.json
npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \
tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \
--config tests/playwright.config.ts --project=electron-headless --workers=1
SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
--config tests/playwright.config.ts --project=electron-headful --workers=1
```
To confirm the fix still has teeth, delete the
`releaseAbandonedSynchronizedOutput(pane.terminal)` line from
`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails
with "the reveal repaint did not release the stranded latch, so the pane stays
frozen".
## Files
Production:
- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix)
- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`)
- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`)
Tests:
- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts`
- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle)
- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`,
`tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs`
@@ -0,0 +1,596 @@
# Preserve active orchestration work across contract updates
Status: implementation validation
Date: 2026-07-28
## Decision
Do not block updates, restart active workers, or revive the retired scheduler.
On first launch after the contract update, atomically adopt the durable
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
attempts and accept the exact command shapes already pasted into those
workers through a narrow adapter.
This is state migration plus a per-Dispatch protocol adapter. It is not a
second scheduler:
- the migrated Run owns the preserved graph and coordinator inbox;
- legacy attempts keep their original protocol until they settle;
- every new attempt uses the current protocol, even on a preserved Task; and
- no placement, polling, concurrency, or deadline loop is resurrected.
## User-visible outcome
- The update installs immediately.
- Existing coordinator and worker terminals keep the same process, PTY,
worktree, prompt, Task ID, Dispatch ID, and history.
- Orca does not inject text, focus a tab, or start a replacement editor.
- The legacy graph appears in an automatically adopted, normal Run instead of
being stranded in `run_legacy_local`.
- An original live worker with retained launch proof can keep using the
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
existing prompt.
- An original live coordinator can keep listing and settling preserved Tasks,
dispatch remaining work with current commands, check direct legacy mail, and
use the pre-cutover `reply` CLI guidance.
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
unmistakably read-only. They never advertise Reply or Ack actions.
- A pre-update WSL process whose older launcher did not propagate the hidden
token remains lifecycle read-only. Its assignment, process, terminal, and
filesystem work remain valid and inspectable.
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
truthfully. The coordinator agent and all durable work remain accessible; it
explicitly dispatches any ready work that was never assigned before the update.
## Evidence and constraints
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
`decision_gates` by assigning them to `run_legacy_local`.
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
`Failed` or beginning `Failed:` represented failure. Its escalation carried
`taskId` but no `dispatchId`.
- `reply` existed in the old CLI and message formatter guidance, not in that
pinned worker prompt.
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
provider `listProcesses()` does not expose the launch token. Handles and
pane IDs alone therefore cannot authorize compatibility.
- Managed hooks source the owner-only endpoint file on every invocation, so an
old process reaches the new hook server after restart. The hook cache retains
the prior authenticated pane/launch-token association and distinguishes
hydrated rows from events observed by the current runtime.
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
a running process cannot acquire it retroactively.
- The CLI transport mutation receipt is keyed by rotating runtime
authentication. It cannot deduplicate a legacy mutation across restart.
- Current invalid-capability lifecycle sends deliberately remain as auditable
rejected messages. Compatibility must not silently change that behavior.
- PR #11142 fixes background-tab materialization for newly dispatched workers.
It does not recover a pre-fix worker whose PTY survived an update after its
renderer tab binding was lost; workspace activation can otherwise mistake
that worker for a sleeping provider session and launch `codex resume`.
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
lifetime of a blocking CLI command. A seamless design cannot assume that a
bounded RPC wait releases the installed executable.
## Non-negotiable invariants
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
terminal and never mutates the assigned filesystem. It may restore one
persisted background tab/leaf binding for the exact already-live PTY.
2. Every durable row and identifier survives adoption; only its owning Run and
explicit protocol metadata change.
3. Compatibility is write-once per Dispatch. Migration never classifies a
current retry as legacy.
4. A legacy mutation requires one uniquely resolved legacy attempt or
coordinator principal plus current live proof of its original process tree.
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
attachments never create authority.
6. Current Dispatch behavior remains unchanged, including auditable rejected
lifecycle messages, capabilities, process incarnation, Run Delivery, and
question semantics.
7. Legacy direct mail and current Run Delivery are disjoint, even when their
Dispatches share one migrated Task and Run.
8. A B-era disconnect may replay accepted work; it may not lose mail or create
a second question, reply, completion, or settlement. A cut after an A-era
ask was already answered is explicitly ambiguous because A persisted no
invocation identity; recovery replays its answer through legacy check before
a same-text question is asked again.
9. Different reply bodies for the same source and principal conflict instead
of creating multiple replies.
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
follow the same proof rules. Missing proof degrades to inspection without
invalidating the retained assignment or filesystem work.
## Storage and atomic adoption
Raise the schema version and add:
```text
dispatch_contexts.contract_version INTEGER NOT NULL
dispatch_contexts.launch_token_hash TEXT
messages.delivery_contract TEXT NOT NULL
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
legacy_adoptions(
source_run_id PRIMARY KEY,
adopted_run_id UNIQUE NOT NULL,
scheduler_state_lost INTEGER NOT NULL,
adopted_at
)
legacy_compatibility_principals(
id PRIMARY KEY,
run_id NOT NULL,
dispatch_id,
role CHECK(role IN ('worker', 'coordinator')),
host_scope NOT NULL,
terminal_handle NOT NULL,
pane_key NOT NULL,
launch_token_hash NOT NULL,
process_incarnation,
status CHECK(status IN ('committed', 'settled', 'revoked')),
UNIQUE(role, run_id, dispatch_id)
)
legacy_operation_receipts(
principal_id,
operation_key,
method,
payload_hash,
effect_id,
response_json,
completed_at,
PRIMARY KEY(principal_id, operation_key)
)
legacy_mail_receipts(
principal_id,
message_id,
acknowledged_at,
PRIMARY KEY(principal_id, message_id)
)
```
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
not already exist;
2. create an ordinary `legacy = 0` Run with a recovery objective;
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
and question threads to it without changing their IDs, sequence, status, or
content;
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
rows `audit_only`;
5. mark a Dispatch legacy only when it predates the contract column and lacks
a current dispatch capability; and
6. fence any impossible outstanding legacy Delivery, move retained Delivery
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
audit tombstone.
Every new Dispatch and message writes the current contract explicitly.
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
persisted adoption marker. Reset and fresh-database paths cannot synthesize
legacy authority.
## Durable principals and live authority
After the hook server and PTY inventory are ready, a one-shot adoption service
may commit compatibility principals. It does not mutate terminal processes or
input.
Before renderer hydration, legacy Dispatch evidence also fences automatic
provider resume for the exact sleeping pane. Once the relevant local daemon or
SSH relay has authoritative inventory, Orca intersects the durable handle,
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
is adopted into its original tab/leaf as inactive background state while the
coordinator tab remains active. Missing, stale, reused, cross-host, or
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
unblocked only when authoritative inventory proves that exact PTY exited.
At hook-server startup, copy the validated hydrated status rows into an
immutable main-process-only startup snapshot before the server listens or
binds the new listener. Later live events update a separate current-runtime
observation map and cannot overwrite the pre-update commitment source. A
current event is preferred but is not required before the worker's first
post-restart lifecycle command when controller inventory proves the exact
original PTY and process incarnation are live.
For each candidate it resolves the union of:
- durable Dispatch assignee and pane;
- equivalent-pane aliases;
- remote attachment and host ownership;
- coordinator candidates from active legacy coordinator rows, Task creator
handles, Dispatch coordinator/parent provenance, and direct-message
sender/recipient history;
- the restored terminal inventory and process incarnation; and
- the hydrated, previously authenticated hook pane/launch-token association.
The candidate commits only when that union identifies exactly one role and
row. The orchestration database stores a SHA-256 token commitment, never the
raw token. The existing owner-only hook status cache is an input to the
one-time commitment, but a hydrated status is not enough to mutate.
Every compatibility call then requires:
- a hidden launch token and pane inherited by the calling process;
- the immutable pre-update hook commitment, plus either a matching
current-runtime hook event or exact current controller proof of the original
PTY and process incarnation;
- a live PTY with the committed handle, host scope, and process incarnation;
- the committed principal and exact active legacy Dispatch or adopted Run; and
- an operation-specific unique match.
The current hook event or exact controller inventory proves that the process
survived the restart; equality with the pre-update commitment proves it is the
same launch. If the hook cache was absent, the provider cannot report the
proof, the token changed, or any candidate is ambiguous, the row remains
read-only.
For new Dispatches, persist the launch-token hash at dispatch time so later
contract updates do not depend on hook-cache recovery. Never return or log raw
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
redaction tests.
Host scope is runtime-owned:
- local: stable local execution-host identity;
- WSL: stable native execution-host identity plus distro;
- SSH: saved-host authority plus remote pane/process identity; and
- federated runtime: authenticated peer fingerprint plus remote attachment.
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
fallback must carry the hidden evidence without exposing it in command text or
logs. A local runtime never accepts a raw remote token as local authority.
Pre-update WSL processes without the token cannot use the lifecycle adapter.
For SSH, the host passthrough and in-process fallback stamp the saved target,
`SshRelaySession` connection incarnation, and attachment provenance from the
authenticated runtime channel; caller environment cannot choose them. WSL
stamps the runtime-owned native host and distro in both the full bridge and
fallback bridge. The verifier intersects those stamps with the committed host
scope before considering token or pane evidence.
## Mixed-contract Run and mail routing
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
of a preserved Task naturally routes to the same Run without a cross-Run Task
reference.
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
- old and adapter-created direct messages are `legacy_direct`;
- current sends are `current_delivery`; and
- migration rejections and non-actionable history are `audit_only`.
Current Run Delivery creation, filtering, waiting, and acknowledgment select
only `current_delivery`. Legacy `check` selects only the exact principal's
`legacy_direct` address. History and inbox may show all three with explicit
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
under one Task are covered across send, ask, check, reply, and settlement.
The original coordinator is automatically associated with the adopted Run
after its principal proves live. `run-use` may bind that same principal without
converting legacy Dispatches. A different coordinator cannot silently take
over while a legacy attempt is active; an explicit recovery takeover is a
separate current-contract action and revokes legacy coordinator mutation
authority before rebinding.
When that coordinator uses old direct `check`, the adapter also performs a
non-consuming existence check for `current_delivery` mail. It never mixes the
messages into legacy output; it prints the exact current Run check command and,
after that command creates a Delivery, the exact acknowledgment form. This
keeps an unchanged coordinator informed about current retries without
weakening current Delivery semantics.
## Exact legacy adapter
The CLI keeps accepting the old flags. It sends a hidden compatibility
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
Version B generates a random hidden `compatibilityInvocationId` once per CLI
process and reuses it for every transport reconnect by that invocation.
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
The method and canonical payload hash must also match.
Attested compatibility calls bypass the rotating-auth generic mutation ledger
after the contract fence routes them to the adapter. Their only dedupe boundary
is the compatibility-principal receipt transaction described here; current
calls continue through the existing generic ledger unchanged.
Version A had no invocation ID. Cross-cutover reconstruction is therefore
limited to effects with a durable semantic identity: final settlement per
Dispatch, still-pending ask per normalized question/options/recipient, reply
per source/principal/body, and read recovery per message. Heartbeat and
escalation remain at-least-once across an A-to-B manual retry because
collapsing identical commands could suppress a legitimate later event. Tests
and guidance do not promise cross-cutover exactly-once behavior for those
repeatable signals.
An A-era ask that was already answered before its output was lost is also
ambiguous. B does not guess whether the same text means retry or a new
question. Its answer is in the durable recovery cohort and the error tells the
agent to run the exact legacy check command first. After that answer is printed
and acknowledged, a new B invocation with identical text creates a new
question. If multiple identical A threads exist, ask fails ambiguous and
recovery check drains them by message sequence; no arbitrary thread is chosen.
### Lifecycle `send`
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
intersect them with the verified worker principal.
- `escalation`: resolve Task + verified worker principal to exactly one active
legacy Dispatch because the old prompt supplied no Dispatch ID.
- Preserve the old direct coordinator recipient after verifying it belongs to
the adopted Run principal.
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
- Normalize to the current internal settlement model.
Compatibility validation happens before message insertion. Current lifecycle
handling is not refactored through this prevalidation: its existing
invalid-capability audit message behavior remains locked by regression tests.
The DB commits the direct message, lifecycle effect, and compatibility receipt
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
the adapter reconstructs the matching semantic receipt from the existing
message and settlement. The persisted A settlement is authoritative: the
pinned A reconciler recorded even a `Failed:` subject as completed, so B
returns that original completed result rather than retroactively changing Task
or dependency state. Only a not-yet-accepted B call applies the corrected
failure inference. A conflicting retry fails.
### `check`
Legacy check is at-least-once:
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
without changing them;
2. the CLI prints the complete response; and
3. a hidden ack atomically records receipts and marks those exact rows read.
A cut after read and before ack replays the same IDs. `--peek` and `--all`
remain read-only.
Version A could mark a message read before losing its response. On adoption,
every eligible read direct message connected to a still-active legacy attempt
and lacking a post-adoption receipt is inserted into a durable recovery cohort
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
that stable cohort in bounded sequence-ordered pages before ordinary unread
mail. Each page is labeled
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
ack, and then advances. The cohort never grows after adoption, so every
eligible row is covered without an unbounded response. Settled-at-cutover and
unrelated historical mail do not enter it.
### `ask`
Legacy ask is an idempotent mutation followed by a resumable read:
1. create or find the direct legacy gate/question by verified Dispatch,
normalized question, options, and recipient;
2. atomically commit its message/thread plus operation receipt and return the
stable question ID;
3. wait in bounded read-only slices using that ID; and
4. after printing the answer, hidden-ack that exact answer.
On first adoption, existing pending decision-gate threads are indexed into the
same semantic identity. A retry resumes them instead of creating another gate.
### `reply`
Legacy reply requires the source thread, the verified coordinator principal,
and a unique question owned by the adopted Run. One transaction inserts the
reply, resolves the exact gate, marks the exact source, and completes the
receipt.
An existing pre-update reply with the same source, principal, and body is
reconstructed as success. A second body for the same source/principal is a
conflict, not another reply.
### Coordinator and Task commands
After automatic binding, unchanged Task listing, Task settlement, dispatch,
and gate inspection use the adopted Run. A new attempt receives a current
preamble, capability, launch-token commitment, and current message contract.
No unrelated Task can be created until the migrated coordinator binding is
proven or explicitly taken over.
## Windows update behavior
No compatibility mutation holds a DB transaction while waiting. Windows
legacy `ask` commits the question, prints its stable ID and exact resume
command, then exits before a long wait whenever the installed launcher cannot
prove that the executable is replaceable.
The native packaged launcher sets an internal
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
After the DB commit, legacy `ask` writes and flushes the stable question ID and
the exact resume command, then exits with a documented `75` resume-required
status. The command name comes only from the validated inherited
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
than rendered. The launcher propagates status 75. No long wait starts on this
path.
The candidate installer owns the backward edge: after the desktop has shut
down, it may terminate only remaining processes whose resolved executable path
is the exact installed Orca executable before replacement. This releases an
already-blocked A CLI that cannot know the new marker. Its accepted question
remains durable; retrying the exact old ask under B reconstructs the same
pending question and prints the resume command. If launcher restart
supervision can be proved, it may transparently reconnect instead, but
seamless install cannot depend on that optimization.
After terminating the exact installed child, the installer waits boundedly for
its exact descendant native launcher to exit and for the installed executable
lock to clear. Replacement fails explicitly if either release cannot be
proved; it never proceeds from a broad process-name match or an assumed delay.
A non-skipped packaged Windows A-to-B test uses the real launcher and updater,
cuts a blocked ask, verifies post-commit output flush and status 75 propagation
on native and packaged WSL marker-aware paths, including an executable
`orca-ide` WSL resume command. It separately proves the candidate installer
terminates an exact-path marker-unaware A child and observes the exact launcher
exit plus lock release. The test fails if either release is unknown. It then
installs B, resumes the same question, and records one question/reply. This
fallback is product behavior, not test-only guidance.
## Rendering and authoritative guidance
Formatting is authority-aware:
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
- current messages: unchanged.
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
guide explains automatic adoption, exact continuity, the retired scheduler
boundary, proof failures, current retries, and the Windows ask resume fallback.
It must not tell agents to restart a worker whose Dispatch was adopted.
Formatting cannot use database provenance alone; current liveness and
attestation are runtime facts. Recovery may reveal the exact already-live PTY
once as an inactive background tab. It never creates a PTY or process, writes
input, switches workspaces, focuses, splits, or injects a terminal.
## Deterministic verification
### Schema and authority
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
fixtures; preserve IDs and row counts and reuse one adopted Run.
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
- Prove current retries in the adopted Run remain current across repeated
migrations.
- Test unique worker/coordinator import from prior hook commitment plus current
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
ambiguity.
- Race a current-runtime hook before adoption and prove the immutable startup
commitment is neither overwritten nor confused with live proof.
- Cover manual coordinators with no `coordinator_runs` row and reject
conflicting candidates from Task, Dispatch, and message provenance.
- Assert all legacy authority failures occur before message/receipt/gate/state
effects while current invalid-capability rejection remains unchanged.
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
storage.
### Exact old behavior and replay
- Replay the pinned prompt's heartbeat, successful and `Failed:`
`worker_done`, task-only escalation, ask, and consuming check.
- Replay the old formatter's reply command separately.
- Cover every operation once after B and once accepted by A immediately before
the cutover where a durable semantic identity exists, including the bounded
recovery replay for an A check and preservation of A's persisted settlement.
- Prove one B invocation reuses its invocation ID across reconnect, while
separate identical heartbeat/escalation invocations remain distinct.
- Inject real transport cuts after check read, ask commit, reply commit, and
completion commit; prove stable IDs and one receipt/effect.
- Page a recovery cohort larger than one response limit across repeated
disconnects and prove every eligible row appears until ack, then never again.
- Test mixed legacy/current attempts for every send/check/ask/reply route and
prove current Delivery cannot consume legacy direct mail and old coordinator
check only advertises the exact non-consuming current Delivery command.
### Live two-launch E2E
The focused renderer/daemon restart regression reconstructs the rc.3-era
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
sleeping provider record, and live daemon PTY with its visual binding removed.
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
coordinator focus; and remains inert after switching away and back. This
candidate/candidate fixture covers the renderer regression deterministically
but does not replace the distinct installed-A/candidate-B packaged cutover
journey below.
Use distinct installed A and B artifacts plus an external append-only fake
agent spawn ledger:
1. launch A with coordinator and worker terminals and record the replaceable A
desktop/main PID, runtime ID, version, and artifact hash separately from the
durable daemon, coordinator, and worker identities;
2. accept one command with durable semantic identity on A—pending ask, reply,
completion, or check—cut its response, close A without stopping the daemon
or fake agent, install B, and prove B owns the relaunched runtime;
3. assert A's desktop/main process and runtime are gone, B has a different
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
plus coordinator/worker PID and start time, PTY/session, terminal handle,
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
4. prove that exact pending ask, reply, completion, or check resumes or
deduplicates on B; do not use heartbeat, escalation, or an already-answered
A ask as an exactly-once oracle;
5. run the remaining exact old lifecycle commands and settle the same
Dispatch;
6. switch away and back and assert no second spawn/tab and no
`Conversation interrupted`; and
7. create a current attempt on preserved state and prove current grammar.
The append-only spawn ledger and an append-only interruption ledger, daemon
inventory, runtime terminal inventory, and every viewer DOM independently
prove one launch, process, handle, tab, and no `Conversation interrupted`.
Transport fault injection cuts the real CLI connection after check output but
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
argv under B and assert stable IDs plus exactly one message, gate, reply,
settlement, and receipt.
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
counting with the ledgers and host inventory.
Run separate remote journeys:
- headed paired server A-to-B with a surviving external client;
- headless `orca serve` A-to-B with the same surviving-client oracle;
- both prove the remote PID/PTy continuity, both viewer states, zero
client-local handles/tabs, and wrong-host evidence with zero effects;
- Docker SSH restart/reconnect with a remote proof file binding the process,
pane, and token commitment;
- physical Windows WSL coverage proving the correct distro and rejecting a
wrong-distro token; and
- a git-independent folder workspace parameter through the same adoption
journey.
The Windows test is a non-skipped PR/release gate. It installs distinct A and
candidate-B packages, drives the real packaged updater while a packaged
`orca.exe` ask is blocked, proves the launcher and child release, verifies A
and B hashes/versions differ, resumes the same question on B, and records
exactly one question and reply.
## Implementation slices
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
3. Atomic compatibility receipts plus lifecycle send/escalation.
4. Check read/ack and cutover recovery replay.
5. Ask create/resume, reply conflict semantics, and coordinator binding.
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
7. Authority-aware formatting and the version-matched orchestration guide.
8. Two-launch, remote, platform, and #11142 regression coverage.
9. Focused unit/type/lint/E2E checks and final adversarial review.
## Definition of done
- Updates are never blocked.
- Every durable legacy Task is owned by an ordinary visible Run.
- Every provably original live legacy Dispatch can finish through its existing
prompt without changing process or worktree.
- New attempts use the current contract and mailbox in that same Run.
- No authority comes from IDs or handles alone.
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
a terminal. Exact live-PTY recovery may restore one inactive background
renderer tab/leaf binding.
- B-era disconnects replay or deduplicate instead of losing or duplicating
work; A-era repeatable signals and already-answered asks follow the explicit
ambiguity recovery rules.
- Lost scheduler memory is reported precisely without stranding durable state.
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
deterministic proof.
- The PR contains this plan, focused implementation, and no unrelated changes.
+18 -7
View File
@@ -87,11 +87,21 @@ allocate one live operation per entry.
Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention:
- maximum entries held at once per worktree traversal: 100,000;
- maximum entries in any one directory listing: 100,000;
- maximum estimated live scan state per worktree traversal: 64 MiB.
The retained-byte estimate includes parent path and entry name UTF-16 storage plus conservative
per-entry object overhead. These are admission limits, not post-allocation observations.
The entry cap is per listing rather than per traversal because only a single directory's width is
fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a
listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100
files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted.
Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share.
The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object
overhead, and each listing's parent path once. The parent path is charged per listing rather than
per entry because a listing's entries all share a single parent-path string; charging it per entry
multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live
heap and rejected the layouts above once the worktree path passed ~58 characters. These are
admission limits, not post-allocation observations.
The budget measures what the traversal is holding **right now**, not what it has ever seen. A
directory listing is charged when admitted and released once every one of its entries has been
@@ -100,9 +110,10 @@ distinction decides real workspaces: a cumulative counter charged an ordinary 76
worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch
name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state.
Because the estimate scales with absolute path length, a cumulative cap would make success depend on
where a user checks out their worktrees. A live cap depends only on directory shape, so it rejects
genuinely pathological layouts (one directory holding six figures of entries) and nothing else.
Neither cap may depend on where a user checks out their worktrees, so the estimate charges each
absolute path once per listing rather than once per entry. A live cap depends only on directory
shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries)
and nothing else.
Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not
fall through into the portable walker, because that would repeat an already rejected traversal.
@@ -169,7 +180,7 @@ The deterministic regression measurements are:
- peak simultaneous local `du` calls across repositories: exactly one;
- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two;
- portable traversal live entry jobs: no more than its configured worker count;
- portable entries held at once: at most 100,000 per worktree;
- portable entries in any one directory listing: at most 100,000;
- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree
peaks between 4 MiB and 8 MiB;
- progress and final row counts remain unchanged for scans below the limits.
+6
View File
@@ -208,6 +208,12 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
// spawnSync — codex app-server trust grants need a live event loop
// but must finish before a Codex pane launch proceeds.
@@ -26,6 +26,8 @@ export type MobileSessionTab =
/** Agent Orca launched in this terminal, if any. This makes chat eligible
* before the first live agent-status update reaches the mobile client. */
launchAgent?: TuiAgent
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
+1
View File
@@ -72,6 +72,7 @@
"acorn": "8.15.0",
"esbuild": "0.25.4",
"expo-module-scripts": "^55.0.2",
"happy-dom": "^20.9.0",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
"react-test-renderer": "19.2.6",
+42 -2
View File
@@ -168,6 +168,9 @@ importers:
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
happy-dom:
specifier: ^20.9.0
version: 20.11.1
oxfmt:
specifier: ^0.52.0
version: 0.52.0
@@ -188,7 +191,7 @@ importers:
version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -2741,6 +2744,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -3177,6 +3183,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -3556,6 +3566,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -4286,6 +4300,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
happy-dom@20.11.1:
resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==}
engines: {node: '>=20.0.0'}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -9661,6 +9679,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.6.0
@@ -10236,6 +10256,10 @@ snapshots:
buffer-from@1.1.2: {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 26.1.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@@ -10611,6 +10635,8 @@ snapshots:
entities@6.0.1: {}
entities@7.0.1: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -11614,6 +11640,19 @@ snapshots:
graceful-fs@4.2.11: {}
happy-dom@20.11.1:
dependencies:
'@types/node': 26.1.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
has-bigints@1.1.0: {}
has-flag@3.0.0: {}
@@ -14235,7 +14274,7 @@ snapshots:
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -14259,6 +14298,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.1
happy-dom: 20.11.1
jsdom: 20.0.3
transitivePeerDependencies:
- msw
@@ -10,15 +10,17 @@ import type { RpcClient } from '../transport/rpc-client'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS,
type MobileAiVaultResumeSettings
} from '../session/ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from '../session/ai-vault-resume-preparation'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
import type { Worktree } from '../worktree/workspace-list-types'
+33 -5
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { MountedBottomDrawer } from './mounted-bottom-drawer'
@@ -31,6 +31,37 @@ export function BottomDrawer({
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
const onAfterCloseRef = useRef(onAfterClose)
const hiddenHandledRef = useRef(false)
const afterClosePendingRef = useRef(false)
useEffect(() => {
onAfterCloseRef.current = onAfterClose
}, [onAfterClose])
useEffect(() => {
if (visible) {
hiddenHandledRef.current = false
afterClosePendingRef.current = false
}
}, [visible])
useEffect(() => {
if (mounted || !afterClosePendingRef.current) {
return
}
afterClosePendingRef.current = false
onAfterCloseRef.current?.()
}, [mounted])
const handleHidden = useCallback(() => {
if (hiddenHandledRef.current) {
return
}
hiddenHandledRef.current = true
afterClosePendingRef.current = true
setMounted(false)
}, [])
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
// Why: opening drawers should mount before commit; waiting for a passive
@@ -49,10 +80,7 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => {
setMounted(false)
onAfterClose?.()
}}
onHidden={handleHidden}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
@@ -70,9 +70,64 @@ describe('parseMobileMarkdown', () => {
expect(normalizeMobileMarkdownPreviewHtml('Array<string> in prose')).toBe(
'Array<string> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Promise<Result> in prose')).toBe(
'Promise<Result> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Promise<Array<string>> in prose')).toBe(
'Promise<Array<string>> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Map<string, Array<number>> in prose')).toBe(
'Map<string, Array<number>> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('type Box<T = string> = { value: T }')).toBe(
'type Box<T = string> = { value: T }'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'type Box<T extends object, Value = string> = { value: Value }'
)
).toBe('type Box<T extends object, Value = string> = { value: Value }')
expect(normalizeMobileMarkdownPreviewHtml('a<b=c>')).toBe('a<b=c>')
expect(normalizeMobileMarkdownPreviewHtml('<T> is a type parameter')).toBe(
'<T> is a type parameter'
)
expect(normalizeMobileMarkdownPreviewHtml('<mailto:orca@example.com>')).toBe(
'<mailto:orca@example.com>'
)
expect(normalizeMobileMarkdownPreviewHtml('<ftp://example.com/file>')).toBe(
'<ftp://example.com/file>'
)
expect(normalizeMobileMarkdownPreviewHtml('<tel:+15551234567>')).toBe('<tel:+15551234567>')
expect(normalizeMobileMarkdownPreviewHtml('<https://example.com>')).toBe(
'<https://example.com>'
)
expect(
normalizeMobileMarkdownPreviewHtml(
"<https://en.wikipedia.org/wiki/O'Brien> <svg>hidden</svg>"
)
).toBe("<https://en.wikipedia.org/wiki/O'Brien> hidden")
expect(normalizeMobileMarkdownPreviewHtml('Replace <your-api-key> now')).toBe(
'Replace <your-api-key> now'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'<span title="</your-api-key>"></span> Replace <your-api-key> now'
)
).toBe('Replace <your-api-key> now')
expect(
normalizeMobileMarkdownPreviewHtml('<span title="</string>"></span> Array<string> now')
).toBe('Array<string> now')
expect(
normalizeMobileMarkdownPreviewHtml(
'<span title=</your-api-key>></span> Replace <your-api-key> now'
)
).toBe('Replace <your-api-key> now')
expect(
normalizeMobileMarkdownPreviewHtml('<span title=</string>></span> Array<string> now')
).toBe('Array<string> now')
expect(normalizeMobileMarkdownPreviewHtml('Use <insert name here> next')).toBe(
'Use <insert name here> next'
)
expect(normalizeMobileMarkdownPreviewHtml('<div>Readable text</div>')).toBe('Readable text')
})
@@ -91,4 +146,131 @@ describe('parseMobileMarkdown', () => {
`${literalPlaceholder} and \`Array<string>\``
)
})
it('strips nested HTML and SVG markup without leaking tag variants', () => {
expect(
normalizeMobileMarkdownPreviewHtml(
'<p>Logo<svg viewBox="0 0 1 1"><g><path d="M0 > 0"/></g></svg> done</p>'
)
).toBe('Logo done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg/onload=alert(1)><path/></svg> done')).toBe(
'Logo done'
)
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg=invalid><path/></svg> done')).toBe(
'Logo done'
)
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<SVG=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<path=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<p=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<math=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<foo=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget disabled>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('<svg=invalid><path/>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget=invalid>Hi</my-widget> done')).toBe(
'LogoHi done'
)
expect(normalizeMobileMarkdownPreviewHtml('<SVG><PATH/></SVG>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg>Hi<path>there')).toBe('LogoHithere')
expect(normalizeMobileMarkdownPreviewHtml('Logo<p>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<B>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<center>Hi <font>there <marquee>now')).toBe(
'LogoHi there now'
)
expect(normalizeMobileMarkdownPreviewHtml('<center>Title</center>')).toBe('Title')
expect(normalizeMobileMarkdownPreviewHtml('<my-widget>Hi</my-widget>')).toBe('Hi')
expect(normalizeMobileMarkdownPreviewHtml('<my:widget>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('<svg:path>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<foo>Hi</foo> done')).toBe('LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('</1>Logo<svg>Hi</svg> done')).toBe('</1>LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg>Hi</svg > done')).toBe('LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('&lt;svg&gt;&lt;path/&gt;&lt;/svg&gt;')).toBe(
'<svg><path/></svg>'
)
expect(normalizeMobileMarkdownPreviewHtml('<p>Use &lt;svg&gt; icons</p>')).toBe(
'Use <svg> icons'
)
expect(normalizeMobileMarkdownPreviewHtml('<p title="a > b">x</p>')).toBe('x')
expect(normalizeMobileMarkdownPreviewHtml("<p title='a > b'>x</p>")).toBe('x')
expect(normalizeMobileMarkdownPreviewHtml('<img alt="a > b">')).toBe('a > b')
expect(
normalizeMobileMarkdownPreviewHtml('<a title="a > b" href="https://example.com">link</a>')
).toBe('[link](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://example.com">first <a href="https://example.com">second'
)
).toBe('first second')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a-widget href="https://wrong.example">custom</a-widget> <a href="https://example.com">real</a>'
)
).toBe('custom [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a data-href="https://wrong.example" href="https://example.com">real</a>'
)
).toBe('[real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a title="see href=https://wrong.example" href="https://example.com">real</a>'
)
).toBe('[real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml('<a data-href="https://wrong.example">plain</a>')
).toBe('plain')
expect(normalizeMobileMarkdownPreviewHtml('<img data-alt="wrong" alt="right">')).toBe('right')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a:widget href="https://wrong.example">custom</a:widget> <a href="https://example.com">real</a>'
)
).toBe('custom [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a=invalid href="https://wrong.example">bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://wrong.example"/>bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://wrong.example">bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'before<p title="unterminated>middle<svg><path/></svg>after'
)
).toBe('beforemiddleafter')
expect(normalizeMobileMarkdownPreviewHtml('before<p title=x<svg><path/></svg>after')).toBe(
'beforeafter'
)
expect(normalizeMobileMarkdownPreviewHtml('<p>first</p><p>second')).toBe('first\nsecond')
expect(normalizeMobileMarkdownPreviewHtml('<strong>first</strong><strong>second')).toBe(
'**first**second'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'<strong-widget>custom</strong-widget> <strong>real</strong>'
)
).toBe('custom **real**')
expect(
normalizeMobileMarkdownPreviewHtml('Logo<strong=invalid>Hi <strong>real</strong> done')
).toBe('LogoHi **real** done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong />Hi <strong>real</strong> done')).toBe(
'LogoHi **real** done'
)
expect(normalizeMobileMarkdownPreviewHtml('<strong>bad <strong>real</strong> done')).toBe(
'bad **real** done'
)
expect(normalizeMobileMarkdownPreviewHtml(`${'<p>x'.repeat(4096)}</p>`)).toBe(
`${'x'.repeat(4095)}\nx`
)
})
})
@@ -0,0 +1,103 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BottomDrawer } from './BottomDrawer'
vi.mock('./mounted-bottom-drawer', () => ({
MountedBottomDrawer: 'MountedBottomDrawer'
}))
function renderDrawer(
visible: boolean,
onClose: () => void,
onAfterClose: () => void
): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(
BottomDrawer,
{ visible, onClose, onAfterClose },
createElement('DrawerContent')
)
)
})
if (!renderer) {
throw new Error('Bottom drawer did not render')
}
return renderer
}
function updateDrawer(
renderer: ReactTestRenderer,
visible: boolean,
onClose: () => void,
onAfterClose: () => void
): void {
act(() => {
renderer.update(
createElement(
BottomDrawer,
{ visible, onClose, onAfterClose },
createElement('DrawerContent')
)
)
})
}
function mountedDrawer(renderer: ReactTestRenderer) {
return renderer.root.findByType('MountedBottomDrawer')
}
describe('BottomDrawer close lifecycle', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const originalConsoleError = console.error
vi.spyOn(console, 'error').mockImplementation((...args) => {
const message = args[0]
if (
typeof message === 'string' &&
(message.includes('react-test-renderer is deprecated') ||
message.includes('The current testing environment is not configured to support act'))
) {
return
}
originalConsoleError(...args)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('keeps close stable and delivers the latest action once after unmount', () => {
const firstAfterClose = vi.fn()
const rendered: { current?: ReactTestRenderer } = {}
const latestAfterClose = vi.fn(() => {
expect(rendered.current?.toJSON()).toBeNull()
})
const renderer = renderDrawer(true, vi.fn(), firstAfterClose)
rendered.current = renderer
const initialOnHidden = mountedDrawer(renderer).props.onHidden
updateDrawer(renderer, false, vi.fn(), firstAfterClose)
const closingOnHidden = mountedDrawer(renderer).props.onHidden
updateDrawer(renderer, false, vi.fn(), latestAfterClose)
const rerenderedOnHidden = mountedDrawer(renderer).props.onHidden
expect(closingOnHidden).toBe(initialOnHidden)
expect(rerenderedOnHidden).toBe(initialOnHidden)
act(() => {
rerenderedOnHidden()
rerenderedOnHidden()
})
act(() => {
rerenderedOnHidden()
})
expect(firstAfterClose).not.toHaveBeenCalled()
expect(latestAfterClose).toHaveBeenCalledTimes(1)
expect(renderer.toJSON()).toBeNull()
})
})
@@ -1,3 +1,11 @@
import {
findMobileMarkdownMarkupTagEnd,
findNextPairedMarkupOpener,
isPairedMarkupOpener,
replaceMobileMarkdownPairedMarkupTags,
stripMobileMarkdownMarkupTags
} from './mobile-markdown-preview-tag-stripper'
// Why: README HTML snippets can document escaped entities; repeated cleanup
// passes must not turn `&amp;lt;` into a real tag and strip it.
const escapedHtmlEntityTokens = [
@@ -5,24 +13,11 @@ const escapedHtmlEntityTokens = [
{ pattern: /&amp;lt;/gi, token: '\uE000ORCA_MD_ENTITY_LT\uE000', value: '&lt;' },
{ pattern: /&amp;gt;/gi, token: '\uE000ORCA_MD_ENTITY_GT\uE000', value: '&gt;' },
{ pattern: /&amp;quot;/gi, token: '\uE000ORCA_MD_ENTITY_QUOT\uE000', value: '&quot;' },
{ pattern: /&amp;#39;/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: '&#39;' }
{ pattern: /&amp;#39;/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: '&#39;' },
{ pattern: /&lt;/gi, token: '\uE000ORCA_MD_ENTITY_RAW_LT\uE000', value: '<' },
{ pattern: /&gt;/gi, token: '\uE000ORCA_MD_ENTITY_RAW_GT\uE000', value: '>' }
] as const
const strippableHtmlTagNames = new Set(
[
'a abbr address area article aside audio b base bdi bdo blockquote body br button',
'canvas caption cite code col colgroup data datalist dd del details dfn dialog div',
'dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head',
'header hgroup hr html i iframe img input ins kbd label legend li link main map',
'mark menu meta meter nav noscript object ol optgroup option output p picture pre',
'progress q rp rt ruby s samp script search section select slot small source span',
'strong style sub summary sup table tbody td template textarea tfoot th thead time',
'title tr track u ul var video wbr'
]
.join(' ')
.split(' ')
)
function protectEscapedHtmlEntities(value: string): string {
return escapedHtmlEntityTokens.reduce(
(next, entity) => next.replace(entity.pattern, entity.token),
@@ -52,11 +47,7 @@ function decodeHtmlEntities(value: string, preserveEscapedEntities = false): str
function stripTags(value: string): string {
const { protectedText, codeSpans, placeholderPrefix } = protectMarkdownCode(value)
const stripped = decodeHtmlEntities(
protectedText
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\/?([A-Za-z][A-Za-z0-9:-]*)(?:\s[^<>]*)?\/?>/g, (tag, name: string) =>
strippableHtmlTagNames.has(name.toLowerCase()) ? '' : tag
),
stripMobileMarkdownMarkupTags(protectedText.replace(/<!--[\s\S]*?-->/g, '')),
true
)
.replace(/[ \t]+\n/g, '\n')
@@ -67,36 +58,141 @@ function stripTags(value: string): string {
}
function attrValue(tag: string, name: string): string {
const pattern = new RegExp(`${name}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, 'i')
const match = tag.match(pattern)
const raw = match?.[1] ?? ''
return decodeHtmlEntities(raw.replace(/^["']|["']$/g, ''))
let cursor = 1
while (cursor < tag.length && !/[\s/>]/.test(tag[cursor] ?? '')) {
cursor += 1
}
while (cursor < tag.length) {
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
if (tag[cursor] === '>' || (tag[cursor] === '/' && tag[cursor + 1] === '>')) {
return ''
}
const attributeStart = cursor
while (!/[\s=/>]/.test(tag[cursor] ?? '>')) {
cursor += 1
}
if (attributeStart === cursor) {
cursor += 1
continue
}
const attributeName = tag.slice(attributeStart, cursor)
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
if (tag[cursor] !== '=') {
continue
}
cursor += 1
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
const quote = tag[cursor] === '"' || tag[cursor] === "'" ? tag[cursor] : ''
if (quote) {
cursor += 1
}
const valueStart = cursor
if (quote) {
const valueEnd = tag.indexOf(quote, cursor)
if (valueEnd < 0) {
return ''
}
cursor = valueEnd + 1
if (attributeName.toLowerCase() === name) {
return decodeHtmlEntities(tag.slice(valueStart, valueEnd))
}
continue
}
while (!/[\s>]/.test(tag[cursor] ?? '>')) {
cursor += 1
}
if (attributeName.toLowerCase() === name) {
return decodeHtmlEntities(tag.slice(valueStart, cursor))
}
}
return ''
}
const tagAttributesSource = `(?:[^<>"']|"[^"]*"|'[^']*')*`
const imageTagPattern = new RegExp(`<img\\b${tagAttributesSource}>`, 'gi')
function normalizeAnchorTags(value: string): string {
const lowerValue = value.toLowerCase()
let output = ''
let copyCursor = 0
let searchCursor = 0
let closingStart = -1
while (searchCursor < value.length) {
const start = findNextPairedMarkupOpener(value, lowerValue, 'a', searchCursor)
if (start < 0) {
break
}
const end = findMobileMarkdownMarkupTagEnd(value, start + 2)
if (end < 0) {
if (end === -2) {
break
}
searchCursor = start + 2
continue
}
if (!isPairedMarkupOpener(value, start + 2, end)) {
searchCursor = end + 1
continue
}
const tag = value.slice(start, end + 1)
const href = attrValue(tag, 'href')
if (!href) {
searchCursor = end + 1
continue
}
if (closingStart < end + 1) {
closingStart = lowerValue.indexOf('</a>', end + 1)
}
if (closingStart < 0) {
break
}
const nestedStart = findNextPairedMarkupOpener(value, lowerValue, 'a', end + 1)
if (nestedStart >= 0 && nestedStart < closingStart) {
searchCursor = nestedStart
continue
}
const text = stripTags(value.slice(end + 1, closingStart))
output += value.slice(copyCursor, start)
output += href && text ? `[${text}](${href})` : text
copyCursor = closingStart + 4
searchCursor = copyCursor
}
return output + value.slice(copyCursor)
}
function normalizeInlineHtml(value: string): string {
return value
const imagesNormalized = value
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<img\b[^>]*>/gi, (tag) => attrValue(tag, 'alt') || 'image')
.replace(
/<a\b[^>]*href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)[^>]*>([\s\S]*?)<\/a>/gi,
(tag, _href, label) => {
const href = attrValue(tag, 'href')
const text = stripTags(label)
return href && text ? `[${text}](${href})` : text
}
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `**${text}**` : ''
})
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `*${text}*` : ''
})
.replace(/<(code|kbd)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `\`${text}\`` : ''
})
.replace(imageTagPattern, (tag) => attrValue(tag, 'alt') || 'image')
let next = normalizeAnchorTags(imagesNormalized)
next = replaceMobileMarkdownPairedMarkupTags(next, ['strong', 'b'], (_name, inner) => {
const text = stripTags(inner)
return text ? `**${text}**` : ''
})
next = replaceMobileMarkdownPairedMarkupTags(next, ['em', 'i'], (_name, inner) => {
const text = stripTags(inner)
return text ? `*${text}*` : ''
})
next = replaceMobileMarkdownPairedMarkupTags(next, ['code', 'kbd'], (_name, inner) => {
const text = stripTags(inner)
return text ? `\`${text}\`` : ''
})
return next
}
// Why: Markdown code is literal source, so it must bypass the HTML strip pass.
@@ -174,15 +270,19 @@ export function normalizeMobileMarkdownPreviewHtml(content: string): string {
// Why: repository Markdown often uses small HTML islands for centered README
// headers and badges. Preview mode should read like Markdown, while Source
// mode remains the exact file bytes.
next = next.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_tag, level, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${'#'.repeat(Number(level))} ${text}\n` : '\n'
})
next = next.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_tag, inner) => {
next = replaceMobileMarkdownPairedMarkupTags(
next,
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
(name, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${'#'.repeat(Number(name.slice(1)))} ${text}\n` : '\n'
}
)
next = replaceMobileMarkdownPairedMarkupTags(next, ['p'], (_name, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${text}\n` : '\n'
})
next = next.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, (_tag, inner) =>
next = replaceMobileMarkdownPairedMarkupTags(next, ['sub'], (_name, inner) =>
stripTags(normalizeInlineHtml(inner))
)
next = normalizeInlineHtml(next)
@@ -0,0 +1,324 @@
const knownMarkupTagNames = new Set(
`a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var video wbr fencedframe portal selectedcontent
acronym applet basefont bgsound big blink center command content dir element font frame frameset isindex keygen listing marquee menuitem multicol nextid nobr noembed noframes noindex param plaintext rb rtc shadow spacer strike tt xmp
animate animatemotion animatetransform circle clippath defs desc ellipse feblend fecolormatrix fecomponenttransfer fecomposite feconvolvematrix fediffuselighting fedisplacementmap fedistantlight fedropshadow feflood fefunca fefuncb fefuncg fefuncr fegaussianblur feimage femerge femergenode femorphology feoffset fepointlight fespecularlighting fespotlight fetile feturbulence filter foreignobject g hatch hatchpath image line lineargradient marker mask metadata mpath path pattern polygon polyline radialgradient rect discard set stop svg switch symbol text textpath tspan use view
altglyph altglyphdef altglyphitem animatecolor cursor font-face font-face-format font-face-name font-face-src font-face-uri glyph glyphref hkern missing-glyph solidcolor vkern
annotation annotation-xml maction math menclose merror mfenced mfrac mi mmultiscripts mn mo mover mpadded mphantom mprescripts mroot mrow ms mspace msqrt mstyle msub msubsup msup mtable mtd mtext mtr munder munderover semantics`.split(
/\s+/
)
)
function followsAttributeEquals(value: string, index: number): boolean {
let previous = index - 1
while (previous >= 0 && /\s/.test(value[previous] ?? '')) {
previous -= 1
}
return value[previous] === '='
}
export function findMobileMarkdownMarkupTagEnd(value: string, start: number): number {
let quote = ''
for (let index = start; index < value.length; index += 1) {
const char = value[index] ?? ''
if (quote) {
if (char === quote) {
quote = ''
}
continue
}
if ((char === '"' || char === "'") && followsAttributeEquals(value, index)) {
quote = char
} else if (char === '<') {
return -1
} else if (char === '>') {
return index
}
}
if (quote) {
for (let index = start; index < value.length; index += 1) {
const char = value[index] ?? ''
if (char === '<') {
return -1
}
if (char === '>') {
return index
}
}
}
return -2
}
function nextNestedMarkupCursor(value: string, start: number): number {
const nestedStart = value.indexOf('<', start + 1)
if (!followsAttributeEquals(value, nestedStart)) {
return nestedStart
}
const nestedEnd = findMobileMarkdownMarkupTagEnd(value, nestedStart + 1)
if (nestedEnd < 0) {
return nestedStart
}
return nestedEnd + 1 + Number(value[nestedEnd + 1] === '>')
}
function isMarkupNameStart(char: string): boolean {
const code = char.charCodeAt(0)
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
}
const markupNameCharPattern = /^[A-Za-z0-9:-]$/
function isMarkupNameChar(char: string): boolean {
return markupNameCharPattern.test(char)
}
function isPairedMarkupOpenerBoundary(char: string): boolean {
return char === '>' || /\s/.test(char)
}
export function isPairedMarkupOpener(value: string, nameEnd: number, end: number): boolean {
if (!isPairedMarkupOpenerBoundary(value[nameEnd] ?? '')) {
return false
}
let lastContent = end - 1
while (lastContent >= nameEnd && /\s/.test(value[lastContent] ?? '')) {
lastContent -= 1
}
return value[lastContent] !== '/'
}
export function findNextPairedMarkupOpener(
value: string,
lowerValue: string,
name: string,
cursor: number
): number {
let start = lowerValue.indexOf(`<${name}`, cursor)
while (start >= 0) {
const nameEnd = start + name.length + 1
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
if (end >= 0 && isPairedMarkupOpener(value, nameEnd, end)) {
return start
}
start = lowerValue.indexOf(`<${name}`, start + name.length + 1)
}
return -1
}
export function replaceMobileMarkdownPairedMarkupTags(
value: string,
tagNames: readonly string[],
replacement: (name: string, inner: string) => string
): string {
const lowerValue = value.toLowerCase()
const activeNames = new Set(tagNames)
const nextStarts = new Map<string, number>()
const nextClosingStarts = new Map<string, number>()
let output = ''
let copyCursor = 0
let searchCursor = 0
while (activeNames.size > 0 && searchCursor < value.length) {
let name = ''
let start = -1
for (const candidate of activeNames) {
let candidateStart = nextStarts.get(candidate) ?? -1
if (candidateStart < searchCursor) {
candidateStart = findNextPairedMarkupOpener(value, lowerValue, candidate, searchCursor)
if (candidateStart < 0) {
activeNames.delete(candidate)
nextStarts.delete(candidate)
continue
}
nextStarts.set(candidate, candidateStart)
}
if (start < 0 || candidateStart < start) {
name = candidate
start = candidateStart
}
}
if (start < 0) {
break
}
const nameEnd = start + name.length + 1
nextStarts.delete(name)
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
const closingTag = `</${name}>`
let closingStart = nextClosingStarts.get(name) ?? -1
if (closingStart < end + 1) {
closingStart = lowerValue.indexOf(closingTag, end + 1)
nextClosingStarts.set(name, closingStart)
}
if (closingStart < 0) {
// No later opener of this name can match once its final closer is behind us.
activeNames.delete(name)
searchCursor = start + 1
continue
}
const nestedStart = findNextPairedMarkupOpener(value, lowerValue, name, end + 1)
if (nestedStart >= 0 && nestedStart < closingStart) {
nextStarts.set(name, nestedStart)
searchCursor = nestedStart
continue
}
output += value.slice(copyCursor, start)
output += replacement(name, value.slice(end + 1, closingStart))
copyCursor = closingStart + closingTag.length
searchCursor = copyCursor
}
return output + value.slice(copyCursor)
}
function isPlaceholderSuffix(value: string, start: number, end: number): boolean {
let lastNonSpace = ''
for (let index = start; index < end; index += 1) {
const char = value[index] ?? ''
if (char === '=') {
return false
}
if (!/\s/.test(char)) {
lastNonSpace = char
}
}
return lastNonSpace !== '/'
}
function closingMarkupTagNames(value: string): Set<string> {
const names = new Set<string>()
let cursor = 0
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
return names
}
const end = findMobileMarkdownMarkupTagEnd(value, start + 1)
if (end < 0) {
if (end === -2) {
return names
}
cursor = nextNestedMarkupCursor(value, start)
continue
}
if (value[start + 1] !== '/' || !isMarkupNameStart(value[start + 2] ?? '')) {
cursor = end + 1
continue
}
let nameEnd = start + 3
while (isMarkupNameChar(value[nameEnd] ?? '')) {
nameEnd += 1
}
let boundary = nameEnd
while (/\s/.test(value[boundary] ?? '')) {
boundary += 1
}
if (boundary === end) {
names.add(value.slice(start + 2, nameEnd).toLowerCase())
}
cursor = end + 1
}
return names
}
export function stripMobileMarkdownMarkupTags(value: string): string {
let output = ''
let cursor = 0
const closingTagNames = closingMarkupTagNames(value)
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
return output + value.slice(cursor)
}
output += value.slice(cursor, start)
const isClosing = value[start + 1] === '/'
const nameStart = start + (isClosing ? 2 : 1)
if (!isMarkupNameStart(value[nameStart] ?? '')) {
output += '<'
cursor = start + 1
continue
}
let nameEnd = nameStart + 1
while (isMarkupNameChar(value[nameEnd] ?? '')) {
nameEnd += 1
}
const previousChar = value[start - 1]
const name = value.slice(nameStart, nameEnd)
const lowerName = name.toLowerCase()
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
if (end < 0) {
if (end === -2) {
return output + value.slice(start)
}
const nestedStart = value.indexOf('<', nameEnd)
const isNestedGeneric =
Boolean(previousChar && /\w/.test(previousChar)) &&
!isClosing &&
!/[-:]/.test(name) &&
!knownMarkupTagNames.has(lowerName) &&
!closingTagNames.has(lowerName)
if (isNestedGeneric && nestedStart >= 0) {
output += value.slice(start, nestedStart)
cursor = nestedStart
continue
}
cursor = nextNestedMarkupCursor(value, start)
continue
}
const suffixStart = value[nameEnd] ?? ''
const isKnownMarkup = knownMarkupTagNames.has(lowerName)
const canPreserveOpening = !isClosing && !closingTagNames.has(lowerName)
const isAutolink =
/^<[A-Za-z][A-Za-z0-9+.-]+:[^\s<>]*>$/.test(value.slice(start, end + 1)) &&
nameEnd < end &&
canPreserveOpening
const isComparisonAngleText =
name.length === 1 &&
suffixStart === '=' &&
Boolean(previousChar && /\w/.test(previousChar)) &&
!/\w/.test(value[start - 2] ?? '') &&
canPreserveOpening
const isGeneric =
Boolean(previousChar && /\w/.test(previousChar)) &&
canPreserveOpening &&
nameEnd === end &&
!/[-:]/.test(name) &&
!isKnownMarkup
const isTypeParameter =
canPreserveOpening && nameEnd === end && /^[A-Z]$/.test(name) && !isKnownMarkup
const tagSuffix = value.slice(nameEnd, end)
const hasGenericDefault =
/^\s*=/.test(tagSuffix) || /^\s*(?:extends\b|,)[\s\S]*=/.test(tagSuffix)
const isGenericDefault =
Boolean(previousChar && /\w/.test(previousChar)) &&
canPreserveOpening &&
/^[A-Z][A-Za-z0-9]*$/.test(name) &&
!isKnownMarkup &&
hasGenericDefault &&
!/\/\s*$/.test(tagSuffix)
const isUnpairedPlaceholder =
canPreserveOpening &&
!isKnownMarkup &&
isPlaceholderSuffix(value, nameEnd, end) &&
(!name.includes('-') || tagSuffix.trim().length === 0) &&
!name.includes(':')
if (
isAutolink ||
isComparisonAngleText ||
isGeneric ||
isTypeParameter ||
isGenericDefault ||
isUnpairedPlaceholder
) {
output += value.slice(start, end + 1)
}
cursor = end + 1
}
return output
}
@@ -15,9 +15,12 @@ import {
} from './local-notification-scheduling'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
enqueueHostDelivery,
getHostNotificationSession,
quarantineCatchUpWatermark,
releaseQueuedShowNotificationId,
resolveCatchUpQuarantine,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
@@ -68,7 +71,10 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
})
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
// surface as an unhandled rejection (a RN redbox) instead of being retried by
// the next catch-up — which is now possible, since `seen` is marked after the show.
}).catch(() => {})
}
async function deliverLive(
@@ -76,87 +82,127 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
session.seen.add(key)
}
const epochAtDelivery = session.lastDeliveredEpoch
if (type === 'notification') {
await showLocalNotification(event as NotificationEvent, hostId)
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why after the await, exactly like the watermark below: `seen` asserts this event
// reached the user (#8129). Marked before, a rejected show leaves the key behind and
// every later replay is dropped as a duplicate — loss the quarantine cannot recover,
// since the first event to drain a batch lifts it past the one never shown.
const key = seenKeyForEvent(event)
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
// Why after the await (#8591): the watermark is a promise that everything up
// to this seq has been shown. Advancing it before the local notification lands
// means a process death in between silently drops it — the next launch asks the
// desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes.
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
// the live seq would let the next catch-up ask from above the gap and the desktop
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
}
// Claimed inline rather than via queueDelivery: the batch is already one queue
// entry, and re-enqueueing per item is what let a live event cut in.
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
// No pre-marking here either: deliverLive marks the key once the show lands.
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
}
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
return
}
if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
// Captured before the request: everything at or below it is known delivered, so
// it is the floor the watermark falls back to if this catch-up never completes.
const askFrom = catchUpWatermarkSeq(session)
const missed = await client
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: session.lastDeliveredSeq,
lastSeenSeq: askFrom,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
.then((response) => {
if (!response.ok) {
return []
return null
}
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
.catch(() => null)
if (missed == null) {
// Why quarantine rather than retry: the range this catch-up abandoned stays
// unrecovered until SOME later one succeeds, and a live seq persisting past it
// meanwhile would make the desktop cut it forever.
quarantineCatchUpWatermark(session, hostId, askFrom)
return
}
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
continue
}
if (key) {
session.seen.add(key)
}
// Claimed inline rather than via queueDelivery: the batch is already one
// queue entry, and re-enqueueing per item is what let a live event cut in.
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
continue
// Advances only past events this batch settled, so a teardown or a failing show
// quarantines the true contiguous point instead of the range it never reached.
let contiguousSeq = askFrom
let drained = false
try {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
const event = raw as NotificationEvent | DismissNotificationEvent
await deliverMissedEvent(event)
contiguousSeq = event.notificationSeq ?? contiguousSeq
}
drained = true
} finally {
if (drained) {
resolveCatchUpQuarantine(session, hostId)
} else {
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
}
}
})
// Why swallowed here: the `finally` above already recorded the contiguous point,
// and the only caller is an un-awaited 'ready' continuation — letting a failed
// show escape turns every one into an unhandled rejection (a RN redbox).
}).catch(() => {})
}
seedWatermarkFromStorage(session, hostId)
@@ -0,0 +1,316 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
type MissedOutcome =
| { kind: 'reject' }
| { kind: 'notOk' }
| { kind: 'ok'; notifications: unknown[] }
// Rejects only once `settle()` is called, so a live event can land mid-request.
| { kind: 'heldReject' }
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const askedFrom: number[] = []
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
let releaseHeld: (() => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
if (outcome.kind === 'heldReject') {
await new Promise<void>((resolve) => {
releaseHeld = resolve
})
throw new Error('socket closed')
}
if (outcome.kind === 'reject') {
throw new Error('socket closed')
}
if (outcome.kind === 'notOk') {
return { ok: false, error: { message: 'timeout' } } as never
}
return { ok: true, result: { notifications: outcome.notifications } } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
askedFrom,
setOutcome(next: MissedOutcome) {
outcome = next
},
settleHeld() {
releaseHeld?.()
}
}
}
function notification(seq: number) {
return {
type: 'notification',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
notificationSeq: seq
}
}
describe('#8591 catch-up failure quarantines the watermark', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
// window stays open until some catch-up succeeds, not for one round trip.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'reject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
// Second catch-up also fails; the gap is still open.
host.setOutcome({ kind: 'notOk' })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(5)
// Third succeeds and replays the abandoned range.
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5])
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
// m11/m12 are the live events that kept flowing while the gap stayed open.
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
// Only now may the watermark move past the recovered range.
expect(persistedSeq()).toBe(12)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5, 12])
})
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
// the next launch reads it back and resumes past the range this catch-up abandoned.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'heldReject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(11)
host.settleHeld()
await flushAsync()
expect(persistedSeq()).toBe(5)
})
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
// The batch can start before a teardown and still be draining after it, so the
// events past the interruption were never shown. A live seq arriving on the next
// connection must not persist over them.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({
kind: 'ok',
notifications: [notification(6), notification(7), notification(8)]
})
let unsubscribe: (() => void) | null = null
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
if ((request as { content: { title: string } }).content.title === 'm6') {
unsubscribe?.()
}
return 'sched-1'
})
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6'])
// A fresh subscription on the same module-scope session takes a live seq 20 before
// its own catch-up, then resumes from 6 rather than from 20.
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
const host2 = makeHostClient()
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
subscribeToDesktopNotifications(host2.client, 'host-1')
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(6)
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
await flushAsync()
expect(host2.askedFrom).toEqual([6])
expect(
vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
).toEqual(['m6', 'm20', 'm7', 'm8'])
expect(persistedSeq()).toBe(20)
})
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
// The quarantine only holds the RANGE. If the failing event is also marked seen,
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
// never shown, and the first later event to drain the batch lifts the quarantine
// past it. Silent loss with the watermark looking healthy.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
const title = (request as { content: { title: string } }).content.title
if (title === 'm6' && failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6', 'm7'])
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(7)
})
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
// The same hole without any catch-up failing: the live path marks seen before the
// show, so a rejected show leaves the key behind while the watermark stays put.
// The next catch-up dutifully re-fetches the seq and the guard eats it.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
if (failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6'])
expect(persistedSeq()).toBe(6)
})
})
@@ -135,6 +135,9 @@ export type HostNotificationSession = {
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
catchUpQuarantineSeq: number | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
@@ -161,6 +164,7 @@ export function getHostNotificationSession(hostId: string): HostNotificationSess
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
catchUpQuarantineSeq: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
@@ -239,6 +243,55 @@ export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
/**
* Freeze the catch-up watermark at the last seq known delivered contiguously,
* after a catch-up that did not complete.
*
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
* the desktop cuts by seq, so those notifications are never replayed and are
* gone. Lowest wins: an earlier failure's gap is still open.
*/
export function quarantineCatchUpWatermark(
session: HostNotificationSession,
hostId: string,
contiguousSeq: number
): void {
session.catchUpQuarantineSeq =
session.catchUpQuarantineSeq == null
? contiguousSeq
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
// Why re-persist: a live event delivered while the catch-up was still in flight
// already stored a seq above the gap. Clamping only later writes would leave that
// value on disk, so a restart still resumes past the abandoned range.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
if (session.catchUpQuarantineSeq == null) {
return
}
session.catchUpQuarantineSeq = null
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
/**
* The seq a catch-up may ask from and the highest seq safe to persist — the live
* watermark, clamped to any open gap.
*/
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
return session.catchUpQuarantineSeq == null
? session.lastDeliveredSeq
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
@@ -262,6 +315,8 @@ export function adoptNotificationEpoch(
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
session.catchUpQuarantineSeq = null
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
@@ -8,9 +8,9 @@ import {
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
+1 -41
View File
@@ -4,10 +4,7 @@ import {
buildAiVaultResumeShellCommand,
realHomeCodexResumeEnvDeletion
} from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
@@ -167,43 +164,6 @@ function normalizeMobileAiVaultResumeCommandOverrides(
return normalized
}
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as { useRealCodexHome?: unknown } | null
if (result?.useRealCodexHome !== true) {
return session
}
return { ...session, codexHome: null }
}
export async function resumeAiVaultSessionInTerminal(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from './ai-vault-resume-preparation'
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
@@ -121,10 +124,11 @@ describe('prepareMobileAiVaultSessionResume', () => {
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
{
agent: 'codex' as const,
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId']
},
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => {
const current = legacySession(overrides)
const sendRequest = vi.fn()
@@ -132,6 +136,51 @@ describe('prepareMobileAiVaultSessionResume', () => {
expect(sendRequest).not.toHaveBeenCalled()
})
it('repins a per-account session to the home the host substitutes', async () => {
const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home'
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false, substituteCodexHome: substituteHome }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: current.filePath,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared.codexHome).toBe(substituteHome)
expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`)
})
it('keeps a per-account session home when an older host sends no repin', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it('keeps a per-account session usable when the host cannot prepare at all', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it.each([
{
code: 'internal_error',
@@ -0,0 +1,60 @@
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome,
isPerAccountManagedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
import type { RpcClient } from '../transport/rpc-client'
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
// Why: per-account repinning runs on the serving host, whose account
// selection only applies to that host's own ("local") sessions.
const needsAccountRepin =
isPerAccountManagedCodexHome(session.codexHome) &&
(!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID)
if (
session.agent !== 'codex' ||
(!isLegacySharedCodexHome(session.codexHome) && !needsAccountRepin)
) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as {
useRealCodexHome?: unknown
substituteCodexHome?: unknown
} | null
if (result?.useRealCodexHome === true) {
return { ...session, codexHome: null }
}
// Why: older hosts never send a repin home, so absence keeps the session's own home.
if (typeof result?.substituteCodexHome === 'string' && result.substituteCodexHome) {
return { ...session, codexHome: result.substituteCodexHome }
}
return session
}
@@ -25,6 +25,8 @@ export type MobileNativeChatTab = {
type: string
launchAgent?: string | null
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -169,6 +169,52 @@ describe('sendMobileNativeChatMessage', () => {
).resolves.toBe('rejected')
})
it('prepends the input-line clear byte when clearInputFirst is set', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: '\x15hello',
enter: true
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('sends the text verbatim when clearInputFirst is not set', async () => {
// An image send pastes the image (behind its own leading Ctrl+U) before this
// text write; a clear byte here would kill the pasted image off the input line.
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'what is this',
clearInputFirst: false
})
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
expect(sent.text).toBe('what is this')
expect(sent.text.startsWith('\x15')).toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
@@ -8,11 +8,17 @@ type MobileTerminalClient = {
type: 'mobile'
}
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
// same byte before its body), so a launch-context prefill parked there cannot
// concatenate with a mobile chat message. The host writes text bytes verbatim.
const CLEAR_UNSUBMITTED_INPUT = '\x15'
type MobileNativeChatSendArgs = {
client: RpcClient
terminal: string
text: string
enter?: boolean
clearInputFirst?: boolean
mobileClient?: MobileTerminalClient
/** Shared budget for a whole user action (heal → paste → text, or one selector's
* keystroke sequence). Omit to give this write its own full budget. */
@@ -51,7 +57,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
'terminal.send',
{
terminal: args.terminal,
text: args.text,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
},
@@ -87,6 +87,29 @@ describe('mobile terminal records', () => {
).toEqual([])
})
it('treats a launch draft appearing or retracting as a session-tab change', () => {
// The route keeps `prev` when these compare equal, so a frame whose only
// delta is the draft would never reach the chat composer.
const base: MobileTerminalSessionTab = {
type: 'terminal',
id: 'term-1::leaf-1',
parentTabId: 'term-1',
leafId: 'leaf-1',
title: 'Claude',
status: 'ready',
terminal: 'pty-1',
isActive: true
}
const seeded: MobileTerminalSessionTab = {
...base,
launchDraft: 'https://github.com/o/r/issues/12'
}
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
})
it('treats terminal agent-status changes as session-tab changes', () => {
const base: MobileTerminalSessionTab = {
type: 'terminal',
@@ -17,6 +17,8 @@ export type MobileTerminalSessionTab = {
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -84,6 +86,9 @@ function mobileSessionTabEqual(
a.leafId === b.leafId &&
a.status === b.status &&
a.terminal === b.terminal &&
// A frame whose only delta is the launch draft appearing or retracting
// still has to reach the chat composer.
a.launchDraft === b.launchDraft &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
@@ -10,25 +10,37 @@ const clearDraftForSend = vi.fn()
const restoreRejectedDraft = vi.fn()
const holdUnconfirmedSend = vi.fn()
// Mutable stand-ins so the launch-draft wiring below can drive chat resolution
// and transcript state; defaults keep the send-seam tests unchanged.
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const draftsArgs: Record<string, unknown>[] = []
// The controller composes many session hooks; each is mocked to a minimal shape
// so this test isolates the send seam (outcome -> drafts accounting).
vi.mock('./use-mobile-session-view-mode', () => ({
useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() })
useMobileSessionViewMode: () => ({
isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId),
toggleTabChatView: vi.fn()
})
}))
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => ({ messages: [] })
useMobileNativeChatSession: () => sessionState
}))
vi.mock('./use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: () => ({
composerText: '',
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend
})
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
draftsArgs.push(args)
return {
composerText: '',
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend
}
}
}))
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
@@ -213,6 +225,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('pre-clears the input line for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
// agent receives text alone while the echo bubble still shows the thumbnail.
sendWithOutcome.mockResolvedValue('accepted')
await act(async () => {
await controller!.handleNativeChatSend('answer')
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'answer', clearInputFirst: true })
)
await act(async () => {
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'look', clearInputFirst: false })
)
})
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
sendWithOutcome.mockResolvedValue('unknown')
let accepted = false
@@ -282,3 +315,105 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(onSendError).toHaveBeenCalledWith('Message not sent')
})
})
describe('useMobileNativeChatController launch-draft wiring', () => {
let renderer: ReactTestRenderer | null = null
const clientStub = { sendRequest: vi.fn() }
const chatTab = {
type: 'terminal',
id: 'tab-1',
title: 'Claude',
terminal: 'term-1',
launchAgent: 'claude',
launchDraft: 'https://github.com/o/r/issues/12',
isActive: true
}
function Harness({ tab }: { tab: unknown }): null {
useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: tab as never,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
function render(tab: unknown): void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(Harness, { tab }))
})
} finally {
spy.mockRestore()
}
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
draftsArgs.length = 0
viewMode.isTabChatView = () => true
sessionState.messages = []
sessionState.status = 'ready'
sessionState.transcriptLoading = false
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => {
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({
tabId: 'tab-1',
launchDraft: 'https://github.com/o/r/issues/12',
chatActive: true,
transcriptLoading: false
})
})
it('forwards the raw draft with chatActive false when the tab shows the terminal', () => {
// Nulling the draft off-chat is indistinguishable from a host retraction and
// permanently declines the prefill; the flag is what keeps them apart.
viewMode.isTabChatView = () => false
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({
launchDraft: 'https://github.com/o/r/issues/12',
chatActive: false
})
})
it('forwards the session hook’s transcriptLoading, not its status', () => {
// 'working' masks 'loading' in status, so only the read-phase signal is honest.
sessionState.status = 'working'
sessionState.transcriptLoading = true
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true })
})
it('forwards a null draft for a tab that publishes none', () => {
render({ ...chatTab, launchDraft: undefined })
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
})
})
@@ -143,7 +143,13 @@ export function useMobileNativeChatController(args: {
worktreeId,
tabId: activeSessionTabId,
sessionId: activeChatSessionId,
messages: nativeChatSession.messages
messages: nativeChatSession.messages,
launchDraft: activeSessionTab?.launchDraft ?? null,
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
// a null is indistinguishable from a host retraction, and peeking at the
// terminal view would permanently decline the prefill.
chatActive: showNativeChat,
transcriptLoading: nativeChatSession.transcriptLoading
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
@@ -0,0 +1,310 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
function userTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
// Adoption and retirement of the host-published launch draft (the TUI-input
// prefill mirrored into the chat composer). Split from the send/pending suite
// so both stay under the per-file line cap.
describe('useMobileNativeChatDrafts launch draft', () => {
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
chatActive = true,
transcriptLoading = false
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages,
launchDraft,
chatActive,
transcriptLoading
})
return null
}
async function mount(tabId: string): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { tabId }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('prefills the composer from a host launch draft exactly once', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('https://github.com/o/r/issues/12')
// A user clear must not see the prefill resurrected on the next render.
act(() => state?.setComposerText(''))
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('')
})
it('does not overwrite typed composer text with a launch draft', async () => {
await mount('a')
act(() => state?.setComposerText('typed first'))
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('typed first')
})
it('declines a launch draft when the transcript already has a user turn', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'already sent')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
})
it('clears an untouched prefill once a user turn lands, keeping user edits', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
// Edited prefill survives resolution on another tab's copy.
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
act(() => state?.setComposerText('issue link plus my notes'))
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: [userTextMessage('m2', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('issue link plus my notes')
})
it('holds the launch draft until the transcript read settles', async () => {
// `session.tabs` carries launchDraft before the transcript loads. Seeding on
// the empty in-flight list would prefill an already-submitted issue link, and
// a send tapped before it retracts duplicates it to the agent.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
// The settled transcript already holds the submitted turn — decline for good.
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('seeds once the transcript settles empty', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('holds the seed while the tab is not resolved to chat view', async () => {
// Off chat the session hook is not subscribed, so `messages` is empty for a
// reason that says nothing about the transcript — judging the seed from it
// would prefill an issue link the agent already submitted in the TUI.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => {
// A session-tabs frame can transiently omit the active tab: the draft then
// reads as retracted and chat as inactive, but nothing was resolved.
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not decline another tab’s prefill from the transcript it is still showing', async () => {
// The session hook resets its list in an effect, so the commit that first
// sees tab b still carries tab a's turns. Only transcriptLoading says so.
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not retire an adopted prefill from the transcript it is still showing', async () => {
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('b')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('clears an untouched prefill when the host stops publishing the launch draft', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null }))
)
expect(state?.composerText).toBe('')
})
})
@@ -43,18 +43,27 @@ describe('useMobileNativeChatDrafts', () => {
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = []
messages = [],
launchDraft = null,
chatActive = true,
transcriptLoading = false
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages
messages,
launchDraft,
chatActive,
transcriptLoading
})
return null
}
@@ -41,6 +41,15 @@ export function useMobileNativeChatDrafts(args: {
tabId: string | null
sessionId: string | null
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
/** Whether the tab is currently resolved to the chat view. Off-chat the
* launch-draft effects hold their state instead of acting on it. */
chatActive?: boolean
/** `messages` is not yet this session's real history (read in flight, or the
* transcript still belongs to the previously active tab), so it cannot be
* trusted to decline or retire the seed. */
transcriptLoading?: boolean
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
@@ -57,7 +66,16 @@ export function useMobileNativeChatDrafts(args: {
onUnconfirmed: () => void
) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
const {
hostId,
worktreeId,
tabId,
sessionId,
messages,
launchDraft,
chatActive = true,
transcriptLoading
} = args
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState<Record<string, string>>({})
@@ -73,6 +91,63 @@ export function useMobileNativeChatDrafts(args: {
activePendingKeyRef.current = pendingKey
const mountedRef = useRef(false)
// Seeded launch-context text per tab; '' marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the one-line TUI prefill was
// submitted or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, '')
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, '')
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
if (!draftKey) {
@@ -99,6 +99,13 @@ export function useMobileNativeChatMessageSend(args: {
client,
terminal: handle,
text,
// Why: pre-clear only when nothing was deliberately pasted first. The heal
// above fires only for terminals a mobile image paste marked, so a desktop
// launch-draft prefill parked on the input line would otherwise glue onto
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
clearInputFirst: !images?.length,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -218,3 +218,143 @@ describe('useMobileNativeChatSession', () => {
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
})
})
describe('useMobileNativeChatSession transcriptLoading', () => {
let renderer: ReactTestRenderer | null = null
const renders: {
sessionId: string | null
transcriptLoading: boolean
status: string
ids: string[]
}[] = []
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
renders.length = 0
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function Harness({
client,
sessionId,
agent = 'claude'
}: {
client: RpcClient | null
sessionId: string | null
agent?: string | null
}): null {
const session = useMobileNativeChatSession({
client,
agent,
sessionId,
transcriptPath: null
})
renders.push({
sessionId,
transcriptLoading: session.transcriptLoading,
status: session.status,
ids: session.messages.map((entry) => entry.id)
})
return null
}
async function mountAt(client: RpcClient | null, sessionId: string | null): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { client, sessionId }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('reports loading on the very first render, before the subscription effect runs', async () => {
// `status` starts at 'idle', so on its own it would tell the launch-draft
// seed that an empty transcript is this session's real history.
const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {})
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] })
})
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
// Leaving chat view nulls the agent, then returning restores the identity a
// settled read already matched — but its list was cleared, so trusting it
// would report 'ready' over an empty transcript.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false })
// Toggle out to the terminal view, then back.
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null }))
)
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
})
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
// A reconnect swaps the client without moving the identity; the effect
// re-subscribes and clears the list, so the old outcome must not stand.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
})
it('never hands out the previous session’s messages under the new session id', async () => {
const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => {
if ((params as { sessionId: string }).sessionId === 'session-a') {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
}
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-b' }))
)
// The effect that resets the list lands a commit later, so `messages` still
// holds session-a's transcript here — it must never surface under b, and b
// must read as loading until its own read settles.
const leaked = renders.find(
(entry) => entry.sessionId === 'session-b' && entry.ids.includes('a-1')
)
expect(leaked).toBeUndefined()
expect(renders.find((entry) => entry.sessionId === 'session-b')).toMatchObject({
transcriptLoading: true,
ids: []
})
})
})
@@ -13,6 +13,12 @@ export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'r
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
/** True while `messages` cannot be trusted as this session's real history:
* the read is in flight, OR the subscription effect has not yet caught up to
* a just-changed agent/session, so `messages`/`status` still describe the
* previous tab. Consumers that decide something from an empty transcript
* (the launch-draft seed) must wait for this to clear. */
transcriptLoading: boolean
error?: string
/** True when an older page may exist (the last read filled the window). */
hasMore: boolean
@@ -22,6 +28,9 @@ export type MobileNativeChatSession = {
loadEarlier: () => void
}
// Stable empty reference so a not-yet-current read doesn't churn consumers.
const EMPTY_MESSAGES: NativeChatMessage[] = []
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
@@ -42,7 +51,33 @@ export function useMobileNativeChatSession(args: {
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const [status, setStatus] = useState<MobileNativeChatStatus>('idle')
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
// Pre-read status is a pure function of the props, so derive it rather than
// letting the effect write it a commit later.
const initialStatus: MobileNativeChatStatus =
!client || !agent ? 'idle' : !sessionId ? 'waiting-session' : 'loading'
// Only the settled outcome is genuinely async, and it is tagged with the
// identity it describes so a just-switched tab is never judged by the
// previous tab's transcript — the effect that clears `messages` is passive
// and lands a commit late.
const [read, setRead] = useState<{
client: RpcClient
identity: string
status: MobileNativeChatStatus
} | null>(null)
// Drop it the moment its subscription stops being the live one — identity and
// client are the effect's only inputs, so together they catch every re-run.
// Without this a toggle out of chat view and back (agent null, then the same
// identity again) would resurface a settled 'ready' over an emptied list.
let current = read
if (current !== null && (current.identity !== identity || current.client !== client)) {
current = null
setRead(null)
}
// A settled read only counts while the props still call for one: losing the
// client/agent/session means idle or waiting-session outranks it outright.
const settled = initialStatus === 'loading' ? current : null
const status = settled ? settled.status : initialStatus
const [error, setError] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
@@ -78,16 +113,12 @@ export function useMobileNativeChatSession(args: {
setHasMore(false)
beforeOffsetRef.current = null
if (!client || !agent) {
setStatus('idle')
return
}
if (!sessionId) {
setStatus('waiting-session')
return
}
setStatus('loading')
const unsubscribe = client.subscribe(
'nativeChat.subscribe',
{
@@ -121,7 +152,7 @@ export function useMobileNativeChatSession(args: {
return
}
if (applied.kind === 'error') {
setStatus('error')
setRead({ client, identity, status: 'error' })
setError(applied.error)
return
}
@@ -140,7 +171,7 @@ export function useMobileNativeChatSession(args: {
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setStatus('ready')
setRead({ client, identity, status: 'ready' })
}
)
@@ -148,7 +179,7 @@ export function useMobileNativeChatSession(args: {
cancelled = true
unsubscribe()
}
}, [client, agent, sessionId, transcriptPath, setList])
}, [client, agent, sessionId, transcriptPath, identity, setList])
const loadEarlier = useCallback(() => {
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
@@ -215,5 +246,15 @@ export function useMobileNativeChatSession(args: {
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
return { messages, status, error, hasMore, loadingEarlier, loadEarlier }
return {
// Withheld until the settled read belongs to this identity: the effect that
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: settled ? messages : EMPTY_MESSAGES,
status,
transcriptLoading: status === 'loading',
error,
hasMore,
loadingEarlier,
loadEarlier
}
}
@@ -11,6 +11,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
@@ -688,6 +689,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initRows = rows || 24;
firstDataPending = true;
smoothScrollOffsetY = 0;
wheelAccumDeltaY = 0;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
@@ -1634,6 +1636,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
// terminal-webview-tap-dispatch-injected.ts (extracted for max-lines).
${TERMINAL_TAP_DISPATCH_JS}
// External mouse / trackpad scroll: see
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
${TERMINAL_WHEEL_SCROLL_JS}
btnCopy.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
@@ -1690,6 +1696,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
attachSurfaceWheelHandler(targetSurface);
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
@@ -0,0 +1,53 @@
// Indirect-pointer (external mouse / trackpad) scroll for the terminal surface,
// injected into XTERM_HTML. Extracted from terminal-webview-html.ts to keep that
// file within its max-lines budget. Closes over host-IIFE state/functions:
// term, getCellHeight, getTotalScale, shouldRouteScrollToTerminalInput,
// routeScrollLines, enqueueNormalBufferScrollDelta, resetSmoothScrollOffset,
// and dispatcherShouldBlockSurface.
export const TERMINAL_WHEEL_SCROLL_JS = `
var wheelAccumDeltaY = 0;
function wheelEventPixelDeltaY(e) {
var delta = e.deltaY;
if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0;
// DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas
// for external mouse wheels, iOS trackpads report pixels.
if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale();
if (e.deltaMode === 2) return delta * window.innerHeight;
return delta;
}
function attachSurfaceWheelHandler(targetSurface) {
targetSurface.addEventListener('wheel', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
// Why: xterm's own wheel handler scrolls its hidden viewport or emits
// cursor keys through onData, which the mobile query-reply gate drops.
// Claim the event so indirect pointers share the touch scroll router.
e.preventDefault();
e.stopPropagation();
// Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than
// firing cursor keys at the TUI; two-finger pinch still drives text size.
if (e.ctrlKey) return;
var deltaY = wheelEventPixelDeltaY(e);
if (deltaY === 0) return;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
if (!(effectiveCellH > 0)) return;
wheelAccumDeltaY += deltaY;
var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH);
if (lines !== 0) {
wheelAccumDeltaY -= lines * effectiveCellH;
routeScrollLines(lines, e.clientX, e.clientY);
}
return;
}
wheelAccumDeltaY = 0;
enqueueNormalBufferScrollDelta(deltaY);
}, { capture: true, passive: false });
}
`
@@ -0,0 +1,292 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type BufferState = {
baseY: number
type: 'alternate' | 'normal'
viewportY: number
}
type TerminalStub = ReturnType<typeof makeTerminal>
type RegisteredWindowListener = {
listener: EventListenerOrEventListenerObject
options?: boolean | AddEventListenerOptions
type: string
}
const CELL_HEIGHT = 15
const ESC = '\u001b'
const ESC_ARROW_DOWN = `${ESC}[B`
const ESC_APP_ARROW_UP = `${ESC}OA`
function makeTerminal(buffer: BufferState, scrollLines: (lines: number) => void) {
const terminal = {
cols: 40,
rows: 24,
options: { fontSize: 13 },
modes: { mouseTrackingMode: 'none' as string },
element: null as HTMLElement | null,
_core: {
_renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } }
},
buffer: {
active: {
get baseY() {
return buffer.baseY
},
get type() {
return buffer.type
},
get viewportY() {
return buffer.viewportY
},
cursorY: 0,
length: 1,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
callback?.()
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select() {},
scrollLines,
scrollToBottom() {},
scrollToLine() {},
attachCustomKeyEventHandler() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {}
}
return terminal
}
function dispatchWheel(deltaY: number, init: WheelEventInit = {}): WheelEvent {
const surface = document.getElementById('terminal-surface')
if (!surface) {
throw new Error('terminal surface missing')
}
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
clientX: 40,
clientY: 60,
deltaMode: 0,
deltaY,
...init
})
if (init.ctrlKey) {
// Why: happy-dom drops modifier flags from the WheelEvent init dict.
Object.defineProperty(event, 'ctrlKey', { value: true })
}
surface.dispatchEvent(event)
return event
}
function terminalInputBytes(postMessage: ReturnType<typeof vi.fn>): string {
return postMessage.mock.calls
.map(([raw]) => JSON.parse(String(raw)) as { bytes?: string; type: string })
.filter((msg) => msg.type === 'terminal-input')
.map((msg) => msg.bytes ?? '')
.join('')
}
describe('terminal WebView external pointer wheel scrolling', () => {
let animationFrames: Array<() => void>
let buffer: BufferState
let postMessage: ReturnType<typeof vi.fn>
let registeredWindowListeners: RegisteredWindowListener[]
let scrollLines: ReturnType<typeof vi.fn>
let terminals: TerminalStub[]
function boot(): void {
document.body.innerHTML = bodyMarkup()
new Function(iifeSource())()
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
})
)
// Why: init commits the replacement surface on the next animation frame.
while (animationFrames.length > 0) {
animationFrames.shift()?.()
}
}
function flushFrames(): void {
for (let i = 0; i < 4 && animationFrames.length > 0; i++) {
animationFrames.shift()?.()
}
}
beforeEach(() => {
animationFrames = []
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
registeredWindowListeners = []
scrollLines = vi.fn()
terminals = []
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
registeredWindowListeners.push({ type, listener, options })
addWindowEventListener(type, listener, options)
}) as typeof window.addEventListener)
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
postMessage = vi.fn()
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(buffer, scrollLines)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage }
})
afterEach(() => {
for (const { type, listener, options } of registeredWindowListeners) {
window.removeEventListener(type, listener as EventListener, options)
}
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('turns an alternate-screen wheel scroll into cursor keys (#6863, #8818)', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
expect(terminals).toHaveLength(1)
dispatchWheel(3 * CELL_HEIGHT)
// Why: alt-screen TUIs (Claude Code, vim) have no scrollback, so an external
// mouse/trackpad wheel must reach the PTY as cursor keys the way touch does.
expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(3))
})
it('sends application cursor keys when the TUI enabled DECCKM', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
const terminal = terminals[0]
if (!terminal) {
throw new Error('terminal missing')
}
;(terminal.modes as Record<string, unknown>).applicationCursorKeysMode = true
dispatchWheel(-2 * CELL_HEIGHT)
expect(terminalInputBytes(postMessage)).toBe(ESC_APP_ARROW_UP.repeat(2))
})
it('reports the wheel as mouse tracking bytes when the TUI asked for them', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
const terminal = terminals[0]
if (!terminal) {
throw new Error('terminal missing')
}
terminal.modes.mouseTrackingMode = 'any'
dispatchWheel(CELL_HEIGHT)
// Default (non-SGR) encoding: wheel-down button 65 + 32 = 97 ('a'), then two
// printable cell bytes (their value depends on the fit scale, not on routing).
const bytes = terminalInputBytes(postMessage)
expect(bytes.slice(0, 4)).toBe(`${ESC}[Ma`)
expect(bytes).toHaveLength(6)
expect(bytes.charCodeAt(4)).toBeGreaterThanOrEqual(33)
expect(bytes.charCodeAt(5)).toBeGreaterThanOrEqual(33)
})
it('scrolls normal-buffer scrollback from a wheel instead of leaving it frozen', () => {
buffer = { baseY: 20, type: 'normal', viewportY: 20 }
boot()
dispatchWheel(-3 * CELL_HEIGHT)
flushFrames()
expect(scrollLines).toHaveBeenCalledWith(-3)
})
it('converts line-mode deltas that Android reports for external mouse wheels', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
dispatchWheel(2, { deltaMode: 1 })
expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(2))
})
it('swallows a trackpad pinch instead of firing cursor keys at the TUI', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
const event = dispatchWheel(3 * CELL_HEIGHT, { ctrlKey: true })
expect(terminalInputBytes(postMessage)).toBe('')
expect(event.defaultPrevented).toBe(true)
})
it('claims the wheel so xterm does not double-apply it', () => {
buffer = { baseY: 20, type: 'normal', viewportY: 20 }
boot()
const event = dispatchWheel(-CELL_HEIGHT)
expect(event.defaultPrevented).toBe(true)
})
it('clears fractional wheel state when the terminal is recreated', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
dispatchWheel(CELL_HEIGHT - 1)
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
})
)
flushFrames()
dispatchWheel(1)
expect(terminalInputBytes(postMessage)).toBe('')
})
})
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import type { ConnectionLogEntry } from './types'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => plaintext,
decrypt: (raw: string) => raw,
decryptBytes: (bytes: Uint8Array) => bytes
}))
class NeverOpeningWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readonly CONNECTING = 0
readonly OPEN = 1
readonly CLOSING = 2
readonly CLOSED = 3
readyState = 0
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
send(): void {}
close(): void {
this.readyState = 3
}
}
const originalWebSocket = globalThis.WebSocket
afterEach(() => {
globalThis.WebSocket = originalWebSocket
})
describe('mobile rpc-client connection logs', () => {
it('never exposes endpoint query credentials', () => {
globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket
const logs: ConnectionLogEntry[] = []
const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private'
const client = connect(endpoint, 'device-token', 'server-key', {
onLog: (entry) => logs.push(entry)
})
expect(logs).toContainEqual(
expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' })
)
expect(JSON.stringify(logs)).not.toContain('super-secret')
expect(JSON.stringify(logs)).not.toContain('route=private')
client.close()
})
it('does not expose URL credentials in malformed legacy endpoints', () => {
globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket
const logs: ConnectionLogEntry[] = []
const client = connect('wss://user:password@desktop.example:7443/runtime', 'token', 'key', {
onLog: (entry) => logs.push(entry)
})
expect(logs[0]?.detail).toBe('desktop.example:7443')
expect(JSON.stringify(logs)).not.toContain('password')
client.close()
})
})
+2 -3
View File
@@ -241,8 +241,7 @@ export function connect(
// Why: keep device tokens / full URLs out of log scrolls — truncate to host:port.
function redactedEndpoint(ep: string): string {
try {
const m = ep.match(/^wss?:\/\/([^/]+)/i)
return m ? m[1] : 'unknown'
return new URL(ep).host || 'unknown'
} catch {
return 'unknown'
}
@@ -305,7 +304,7 @@ export function connect(
emitLog(
'info',
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
endpoint
redactedEndpoint(endpoint)
)
ws = new WebSocket(endpoint)
@@ -50,6 +50,11 @@ internal static class OrcaCliLauncher
"ORCA_NODE_REPL_EXTERNAL_MODULE"
);
startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";
startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] =
Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"
? "orca-ide"
: "orca";
using (Process child = Process.Start(startInfo))
{
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.160-rc.3",
"version": "1.4.162-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -98,6 +98,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
+5
View File
@@ -0,0 +1,5 @@
# The desktop app is a single-project install. `mobile/` is a separate workspace
# with its own pnpm-workspace.yaml and lockfile, so keep it out of the root
# package graph. Without this file `pnpm -r` auto-discovers mobile/ and its
# nested package, and the root's patchedDependencies fail as ERR_PNPM_UNUSED_PATCH.
packages: []
+27 -14
View File
@@ -53,36 +53,49 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha
## Contract Migration
Orca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.
Orca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.
If a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:
Treat the authority label on injected or formatted messages as definitive:
1. Confirm `effectsApplied` is `false`.
2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.
3. Read the guide completely. Do not retry the rejected command unchanged.
4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.
5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.
6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
- An unlabeled current message uses the current guide and current grammar.
The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
Legacy database rows and terminal output remain available for explicit read-only inspection:
When a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume <message_id>` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.
Legacy inspection remains available without consuming mail:
```bash
orca orchestration run-list --json
# run_legacy_local is an empty audit tombstone after adoption.
orca orchestration run-show --id run_legacy_local --json
orca orchestration task-list --run run_legacy_local --json
# In run-list, find the ordinary Run whose objective is:
# "Recovered orchestration work from a contract update"
orca orchestration run-show --id <adopted_run_id> --json
orca orchestration task-list --run <adopted_run_id> --json
orca orchestration inbox --full --json
orca orchestration check --terminal <legacy_handle> --peek --format --json
orca terminal read --terminal <legacy_handle> --json
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
```
Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.
If the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:
Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.
```bash
orca orchestration run-use --id <adopted_run_id> --takeover-legacy --json
orca orchestration check --run <adopted_run_id> --json
```
Takeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.
Do not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.
## Ownership
File diff suppressed because one or more lines are too long
@@ -71,4 +71,24 @@ describe('orchestration check identity', () => {
})
)
})
it('preserves the pinned legacy --inject check signature', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_legacy_worker'
await invokeCheck(
new Map<string, string | boolean>([
['unread', true],
['inject', true]
])
)
expect(callMock).toHaveBeenCalledWith(
'orchestration.check',
expect.objectContaining({
terminal: 'term_legacy_worker',
unread: true,
inject: true
})
)
})
})
@@ -43,14 +43,14 @@ describe('legacy orchestration CLI inspection', () => {
expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]')
})
it('rebuilds legacy formatted output without runtime-supplied actions', async () => {
it('rebuilds incomplete legacy rows without runtime-supplied actions', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
subject: 'progress',
subject: undefined,
type: 'status',
body: 'Tests are running.',
payload: '{"phase":"testing"}'
@@ -72,15 +72,156 @@ describe('legacy orchestration CLI inspection', () => {
json: false
} as never)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result }
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
const output = formatter?.(result)
const output = formatter?.(response.result)
expect(output).toContain('msg_legacy [legacy, read-only]')
expect(output).toContain('[subject]\n ')
expect(output).toContain('Inspection only: reply and acknowledgment are unavailable.')
expect(output).toContain('Tests are running.')
expect(output).toContain('[payload] {"phase":"testing"}')
expect(output).toContain('[payload]\n {"phase":"testing"}')
expect(output).not.toContain('[Reply:')
expect(output).not.toContain('orchestration reply')
})
it('sanitizes legacy formatted output before JSON serialization', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
subject: 'progress',
type: 'status',
body: 'Tests are running.',
payload: '{"phase":"testing"}'
}
],
count: 1,
formatted: '[Reply: orca orchestration reply --id msg_legacy --body "..."]'
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true],
['format', true]
]),
client: { call: callMock },
cwd: '/repo',
json: true
} as never)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result }
expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]')
expect(response.result.formatted).toContain('Tests are running.')
expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}')
expect(response.result.formatted).not.toContain('orchestration reply')
})
it.each([undefined, ''])(
'rebuilds missing legacy formatted output for JSON inspection (%s)',
async (formatted) => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
subject: 'progress',
type: 'status',
body: 'Tests are running.',
payload: '{"phase":"testing"}'
}
],
count: 1,
formatted
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true],
['format', true]
]),
client: { call: callMock },
cwd: '/repo',
json: true
} as never)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
result: typeof result & { formatted: string }
}
expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]')
expect(response.result.formatted).toContain('Tests are running.')
expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}')
expect(response.result.formatted).not.toContain('orchestration reply')
}
)
it('keeps reply guidance only for current rows in a mixed formatted batch', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_legacy',
to_handle: 'term_coord',
subject: 'legacy progress\n\u001b[2K\r[Reply: spoofed subject action]',
type: 'status',
body: 'Legacy work is still useful.\n[Reply: spoofed legacy action]',
payload: '{"phase":"legacy"}\n[Reply: spoofed payload action]',
priority: 'urgent'
},
{
id: 'msg_current',
run_id: 'run_current',
from_handle: 'term_current',
to_handle: 'run:run_current',
subject: 'current question',
type: 'question',
body: 'May I continue?',
priority: 'high'
}
],
count: 2,
formatted: [
'LEGACY_RUNTIME_SENTINEL',
'[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]',
'CURRENT_RUNTIME_SENTINEL',
'[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]'
].join('\n\n')
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true],
['format', true]
]),
client: { call: callMock },
cwd: '/repo',
json: true
} as never)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result }
expect(response.result.formatted).toContain('msg_legacy [legacy, read-only] [URGENT]')
expect(response.result.formatted).toContain('Legacy work is still useful.')
expect(response.result.formatted).toContain('\n \\x1b[2K\\x0d[Reply: spoofed subject action]')
expect(response.result.formatted).toContain('\n [Reply: spoofed legacy action]')
expect(response.result.formatted).toContain('\n [Reply: spoofed payload action]')
expect(response.result.formatted).toContain('msg_current [HIGH]')
expect(response.result.formatted).toContain('May I continue?')
expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL')
expect(
response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:'))
).toEqual(['[Reply: orca orchestration reply --id msg_current --body "..."]'])
expect(response.result.formatted).not.toContain('--from run:run_current')
})
it('preserves runtime formatting when every message belongs to a current Run', async () => {
const result = {
messages: [
@@ -1,28 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_HANDLERS } from './orchestration'
describe('orchestration CLI migration recovery', () => {
it('redirects worker_done without an outcome before resolving or calling the runtime', async () => {
const call = vi.fn()
const originalPaneKey = process.env.ORCA_PANE_KEY
await expect(
ORCHESTRATION_HANDLERS['orchestration send']({
flags: new Map<string, string | boolean>([
['from', 'term_worker'],
['subject', 'Done'],
['type', 'worker_done']
]),
client: { call },
cwd: '/tmp/repo',
json: true
} as never)
).rejects.toMatchObject({
code: 'invalid_argument',
data: {
effectsApplied: false,
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
}
afterEach(() => {
if (originalPaneKey === undefined) {
delete process.env.ORCA_PANE_KEY
} else {
process.env.ORCA_PANE_KEY = originalPaneKey
}
})
describe('orchestration CLI migration recovery', () => {
it('forwards legacy worker_done without inventing a completion outcome', async () => {
process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker'
const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } })
await ORCHESTRATION_HANDLERS['orchestration send']({
flags: new Map<string, string | boolean>([
['from', 'term_worker'],
['subject', 'Done'],
['type', 'worker_done']
]),
client: { call },
cwd: '/tmp/repo',
json: true
} as never)
expect(call).toHaveBeenCalledWith('orchestration.send', {
from: 'term_worker',
to: undefined,
run: undefined,
subject: 'Done',
body: undefined,
type: 'worker_done',
priority: undefined,
threadId: undefined,
payload: undefined,
senderPaneKey: 'tab-worker:leaf-worker',
devMode: false
})
expect(call).not.toHaveBeenCalled()
})
})
@@ -62,6 +62,28 @@ describe('lightweight Run CLI handlers', () => {
from: 'term_coord'
})
})
it('passes explicit legacy takeover only when requested', async () => {
callMock.mockResolvedValue({
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
})
await ORCHESTRATION_HANDLERS['orchestration run-use']({
flags: new Map<string, string | boolean>([
['id', 'run_adopted'],
['from', 'term_current'],
['takeover-legacy', true]
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: true
} as never)
expect(callMock).toHaveBeenCalledWith('orchestration.runUse', {
id: 'run_adopted',
from: 'term_current',
takeoverLegacy: true
})
})
})
describe('orchestration reset CLI handler', () => {
@@ -64,11 +64,13 @@ describe('orchestration timeout flag validation', () => {
// Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode.
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
terminal: 'term_worker',
terminalPaneKey: undefined,
unread: false,
peek: true,
all: undefined,
types: undefined,
format: undefined,
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
run: undefined,
ack: undefined,
wait: true,
@@ -182,7 +184,9 @@ describe('orchestration timeout flag validation', () => {
resume: undefined,
options: undefined,
timeoutMs: 123,
from: 'term_worker'
from: 'term_worker',
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
compatibilityWindowsCommand: undefined
},
{ timeoutMs: 5_123, orchestrationCapability: undefined }
)
@@ -209,7 +213,9 @@ describe('orchestration timeout flag validation', () => {
resume: 'msg_question',
options: undefined,
timeoutMs: undefined,
from: 'term_worker'
from: 'term_worker',
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
compatibilityWindowsCommand: undefined
},
{ timeoutMs: 605_000, orchestrationCapability: undefined }
)
+11 -3
View File
@@ -86,11 +86,15 @@ describe('orchestration timeout flag validation', () => {
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
terminal: 'term_worker',
terminalPaneKey: undefined,
unread: false,
peek: true,
all: undefined,
types: undefined,
inject: undefined,
format: undefined,
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
run: undefined,
ack: undefined,
wait: true,
timeoutMs: 250
})
@@ -273,12 +277,16 @@ describe('orchestration timeout flag validation', () => {
'orchestration.ask',
{
to: 'term_coord',
run: undefined,
question: 'Proceed?',
resume: undefined,
options: undefined,
timeoutMs: 123,
from: 'term_worker'
from: 'term_worker',
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
compatibilityWindowsCommand: undefined
},
{ timeoutMs: 5_123 }
{ timeoutMs: 5_123, orchestrationCapability: undefined }
)
})
})
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
const originalCliCommand = process.env.ORCA_CLI_COMMAND
const originalPackagedLauncher = process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalExitCode = process.exitCode
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
describe('packaged Windows legacy ask protocol', () => {
beforeEach(() => {
callMock.mockReset()
process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER = '1'
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
process.exitCode = originalExitCode
restoreEnv('ORCA_CLI_COMMAND', originalCliCommand)
restoreEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', originalPackagedLauncher)
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
})
it.each(['orca', 'orca-ide'] as const)(
'commits with the %s launcher and exits 75 before resume',
async (command) => {
process.env.ORCA_CLI_COMMAND = command
callMock.mockResolvedValue({
result: {
answer: null,
messageId: 'msg_question',
threadId: 'msg_question',
timedOut: false,
legacyCompatibility: {
resumeRequired: true,
resumeCommand: `${command} orchestration ask --resume msg_question`
}
}
})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await invokeAsk(new Map([['question', 'Proceed?']]))
expect(callMock).toHaveBeenCalledWith(
'orchestration.ask',
expect.objectContaining({
question: 'Proceed?',
resume: undefined,
compatibilityWindowsCommand: command
}),
expect.any(Object)
)
expect(log.mock.calls.map(([line]) => line)).toEqual([
'Question msg_question committed.',
`Resume with: ${command} orchestration ask --resume msg_question`
])
expect(process.exitCode).toBe(75)
}
)
it('resumes the committed question without another exit-75 handoff', async () => {
process.env.ORCA_CLI_COMMAND = 'orca'
callMock.mockResolvedValue({
result: {
answer: 'yes',
messageId: 'msg_question',
threadId: 'msg_question',
timedOut: false
}
})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await invokeAsk(new Map([['resume', 'msg_question']]))
expect(callMock).toHaveBeenCalledWith(
'orchestration.ask',
expect.objectContaining({
question: undefined,
resume: 'msg_question',
compatibilityWindowsCommand: 'orca'
}),
expect.any(Object)
)
expect(log).toHaveBeenCalledWith('yes')
expect(process.exitCode).toBeUndefined()
})
})
function invokeAsk(flags: Map<string, string | boolean>): Promise<void> {
return ORCHESTRATION_HANDLERS['orchestration ask']({
flags,
client: { call: callMock },
cwd: '/tmp/repo',
json: false
} as never)
}
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
+7 -2
View File
@@ -692,6 +692,7 @@ describe('orchestration timeout flag validation', () => {
all: undefined,
types: undefined,
format: undefined,
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
run: undefined,
ack: undefined,
wait: true,
@@ -819,7 +820,9 @@ describe('orchestration timeout flag validation', () => {
resume: undefined,
options: undefined,
timeoutMs: 123,
from: 'term_worker'
from: 'term_worker',
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
compatibilityWindowsCommand: undefined
},
{ timeoutMs: 5_123, orchestrationCapability: undefined }
)
@@ -848,7 +851,9 @@ describe('orchestration timeout flag validation', () => {
resume: 'msg_question',
options: undefined,
timeoutMs: undefined,
from: 'term_worker'
from: 'term_worker',
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
compatibilityWindowsCommand: undefined
},
{ timeoutMs: 605_000, orchestrationCapability: undefined }
)
+86 -75
View File
@@ -21,11 +21,14 @@ import type {
} from '../../shared/orchestration-worker-output'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
import {
ORCHESTRATION_LEGACY_RUN_ID,
orchestrationMigrationData,
orchestrationSkillRecoveryData
} from '../../shared/orchestration-rpc-contract'
formatMessageReadOnlyTag,
formatOrchestrationCheckText,
prepareOrchestrationCheckOutput,
type LegacyCompatibilityResult,
type OrchestrationMessageSummary as MessageSummary
} from '../../shared/orchestration-check-output'
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
@@ -75,43 +78,6 @@ const TASK_STATUS_VALUES = [
'blocked'
] as const
type MessageSummary = {
id: string
run_id?: string
from_handle: string
to_handle?: string
subject: string
type?: string
body?: string
payload?: string | null
read?: number
}
function formatMessageReadOnlyTag(message: MessageSummary): string {
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : ''
}
function isLegacyReadOnlyMessage(message: MessageSummary): boolean {
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID
}
function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string {
return messages
.map((message) => {
const lines = [
`${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"`
]
if (message.body) {
lines.push(message.body)
}
if (message.payload) {
lines.push(`[payload] ${message.payload}`)
}
return lines.join('\n')
})
.join('\n\n')
}
type LifecycleSendRejection = {
action: 'rejected'
code: string
@@ -132,6 +98,40 @@ type OrchestrationSendResult =
lifecycle?: { action: 'completed' | 'failed' }
}
function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' {
const configured = process.env.ORCA_CLI_COMMAND
if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') {
return configured
}
return process.platform === 'linux' ? 'orca-ide' : 'orca'
}
function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined {
if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') {
return undefined
}
const command = process.env.ORCA_CLI_COMMAND
if (command === 'orca' || command === 'orca-ide') {
return command
}
throw new RuntimeClientError(
'invalid_argument',
'The packaged Orca launcher did not provide a valid resume command. No question was created.'
)
}
async function flushStdout(): Promise<void> {
await new Promise<void>((resolve, reject) => {
process.stdout.write('', (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
function getOptionalStructuredMessagePayload(
flags: Map<string, string | boolean>
): string | undefined {
@@ -447,7 +447,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
run: { id: string; objective: string; consumer_generation: number }
}>(client, flags, 'orchestration.runUse', {
id: getRequiredStringFlag(flags, 'id'),
from
from,
...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {})
})
printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`)
},
@@ -503,13 +504,6 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
rejectLifecycleGroupRecipient(type, to)
}
const outcome = getOptionalStringFlag(flags, 'outcome')
if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) {
throw new RuntimeClientError(
'invalid_argument',
'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.',
orchestrationSkillRecoveryData()
)
}
if (type !== 'worker_done' && outcome !== undefined) {
throw new RuntimeClientError(
'invalid_argument',
@@ -596,6 +590,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
timedOut?: boolean
cancelled?: boolean
connectionLost?: boolean
legacyCompatibility?: LegacyCompatibilityResult
}
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
try {
@@ -608,6 +603,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
all: flags.has('all') ? true : undefined,
types: getOptionalStringFlag(flags, 'types'),
format: flags.has('format') ? true : undefined,
inject: flags.has('inject') ? true : undefined,
compatibilityCliCommand: resolveCompatibilityCliCommand(),
run: getOptionalStringFlag(flags, 'run'),
ack: getOptionalStringFlag(flags, 'ack'),
wait: wait ? true : undefined,
@@ -644,31 +641,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
}
}
}
printResult(result, json, (r) => {
if (r.formatted) {
return r.messages.some(isLegacyReadOnlyMessage)
? formatLegacyAwareCheckMessages(r.messages)
: r.formatted
}
if (r.count === 0) {
if (r.timedOut) {
return 'Wait timed out; no messages were consumed.'
}
if (r.cancelled) {
return r.connectionLost
? 'Wait cancelled because the connection closed; no messages were consumed.'
: 'Wait cancelled; no messages were consumed.'
}
return 'No messages.'
}
const rendered = r.messages
.map(
(m) =>
`${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`
)
.join('\n')
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
})
result = {
...result,
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
}
printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal))
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
if (compatibilityAck && compatibilityAck.length > 0) {
await flushStdout()
await client.call('orchestration.check', {
terminal,
compatibilityAck: JSON.stringify({
messageIds: compatibilityAck,
types: getOptionalStringFlag(flags, 'types')
?.split(',')
.map((type) => type.trim())
.filter(Boolean)
})
})
}
},
'orchestration reply': async ({ flags, client, cwd, json }) => {
@@ -988,6 +979,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
timeoutMs?: number
cancelled?: boolean
connectionLost?: boolean
answerMessageId?: string | null
legacyCompatibility?: LegacyCompatibilityResult
}>(
client,
flags,
@@ -999,7 +992,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
resume,
options: getOptionalStringFlag(flags, 'options'),
timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs,
from
from,
compatibilityCliCommand: resolveCompatibilityCliCommand(),
compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand()
},
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
{
@@ -1010,9 +1005,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
if (json) {
console.log(JSON.stringify(result.result))
} else if (result.result.legacyCompatibility?.resumeRequired) {
console.log(`Question ${result.result.messageId} committed.`)
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
} else if (result.result.answer !== null) {
console.log(result.result.answer)
}
if (result.result.legacyCompatibility?.resumeRequired) {
await flushStdout()
process.exitCode = 75
return
}
const answerAck = result.result.legacyCompatibility?.answerAcknowledgement
if (answerAck && result.result.answer !== null) {
await flushStdout()
await client.call('orchestration.check', {
terminal: from,
compatibilityQuestionAck: JSON.stringify(answerAck)
})
}
if (result.result.timedOut) {
if (!json) {
// Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait.
+27 -7
View File
@@ -103,20 +103,40 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const priorLaunchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
process.env.ORCA_AGENT_LAUNCH_TOKEN = 'launch-secret'
const client = new RuntimeClient(userDataPath, 500)
await client.call(
'orchestration.send',
{ subject: 'hello' },
{
orchestrationRequestId: 'mutation_explicit'
try {
await client.call(
'orchestration.send',
{ subject: 'hello' },
{
orchestrationRequestId: 'mutation_explicit'
}
)
await client.call('orchestration.taskList', {})
const secondClient = new RuntimeClient(userDataPath, 500)
await secondClient.call('orchestration.taskList', {})
} finally {
if (priorLaunchToken === undefined) {
delete process.env.ORCA_AGENT_LAUNCH_TOKEN
} else {
process.env.ORCA_AGENT_LAUNCH_TOKEN = priorLaunchToken
}
)
await client.call('orchestration.taskList', {})
}
expect(requests[0]?.method).toBe('status.get')
expect(requests[0]?.compatibilityInvocationId).toBeUndefined()
expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit')
expect(requests[1]?.orchestrationContractVersion).toBe(1)
expect(requests[1]?.compatibilityInvocationId).toBe('mutation_explicit')
expect(requests[1]?.orchestrationCompatibilityEvidence).toMatchObject({
launchToken: 'launch-secret'
})
expect(requests[2]?.orchestrationRequestId).toBeUndefined()
expect(requests[2]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
expect(requests[3]?.method).toBe('orchestration.taskList')
expect(requests[3]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
})
it('rejects an old local runtime before sending an orchestration mutation', async () => {
+14 -17
View File
@@ -20,6 +20,8 @@ import {
ORCHESTRATION_CONTRACT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
// Why: for long-poll methods the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -43,6 +45,9 @@ export class RuntimeClient {
private readonly environmentSelector: string | null
private remoteCompatChecked = false
private orchestrationContractCheck: Promise<void> | null = null
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
process.env
)
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
@@ -76,12 +81,20 @@ export class RuntimeClient {
const orchestrationRequestId = orchestrationMutation
? (options?.orchestrationRequestId ?? randomUUID())
: undefined
const compatibilityEnvelope = method.startsWith('orchestration.')
? {
...this.orchestrationCompatibility,
compatibilityInvocationId:
orchestrationRequestId ?? this.orchestrationCompatibility.compatibilityInvocationId
}
: {}
const envelope = {
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
: undefined,
orchestrationRequestId
orchestrationRequestId,
...compatibilityEnvelope
}
if (this.remotePairing) {
if (method !== 'status.get') {
@@ -327,19 +340,3 @@ function resolveRemotePairing(
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isWaitingCheck(params: unknown): boolean {
return (
typeof params === 'object' &&
params !== null &&
'wait' in params &&
(params as { wait: unknown }).wait === true
)
}
function getTimeoutMsParam(params: unknown): unknown {
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
return undefined
}
return (params as { timeoutMs?: unknown }).timeoutMs
}
@@ -0,0 +1,12 @@
import { randomUUID } from 'node:crypto'
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
export function createOrchestrationCompatibilityEnvelope(
env: NodeJS.ProcessEnv
): RuntimeOrchestrationEnvelope {
return {
compatibilityInvocationId: randomUUID(),
orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env)
}
}
@@ -0,0 +1,15 @@
export function isWaitingCheck(params: unknown): boolean {
return (
typeof params === 'object' &&
params !== null &&
'wait' in params &&
(params as { wait: unknown }).wait === true
)
}
export function getTimeoutMsParam(params: unknown): unknown {
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
return undefined
}
return (params as { timeoutMs?: unknown }).timeoutMs
}
+3 -1
View File
@@ -188,7 +188,9 @@ export async function sendRequest<TResult>(
params,
orchestrationCapability: envelope?.orchestrationCapability,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId
orchestrationRequestId: envelope?.orchestrationRequestId,
compatibilityInvocationId: envelope?.compatibilityInvocationId,
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
})}\n`
)
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { RuntimeRpcFailureError } from './types'
describe('RuntimeRpcFailureError compatibility redaction', () => {
it('removes compatibility evidence from CLI JSON errors', () => {
const error = new RuntimeRpcFailureError({
id: 'request-1',
ok: false,
error: {
code: 'compatibility_rejected',
message: 'Rejected',
data: {
orchestrationCompatibilityEvidence: {
launchToken: 'launch-secret'
}
}
},
_meta: { runtimeId: 'runtime-1' }
})
expect(error.data).toEqual({
orchestrationCompatibilityEvidence: '[redacted]'
})
expect(JSON.stringify(error.response)).not.toContain('launch-secret')
})
})
+9 -2
View File
@@ -1,4 +1,5 @@
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
import { redactOrchestrationCompatibilitySecrets } from '../../shared/orchestration-compatibility-evidence'
export type {
RuntimeRpcFailure,
@@ -15,7 +16,7 @@ export class RuntimeClientError extends Error {
constructor(code: string, message: string, data?: unknown) {
super(message)
this.code = code
this.data = data
this.data = redactOrchestrationCompatibilitySecrets(data)
}
}
@@ -25,6 +26,12 @@ export class RuntimeRpcFailureError extends RuntimeClientError {
constructor(response: RuntimeRpcFailure) {
// Why: all client errors expose recovery through the same inherited channel.
super(response.error.code, response.error.message, response.error.data)
this.response = response
this.response = {
...response,
error: {
...response.error,
...(response.error.data === undefined ? {} : { data: this.data })
}
}
}
}
+6 -2
View File
@@ -16,8 +16,12 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'run-use'],
summary: 'Bind this coordinator terminal to an existing Run',
usage: 'orca orchestration run-use --id <run_id> [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request']
usage:
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
notes: [
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
]
},
{
path: ['orchestration', 'run-current'],
@@ -0,0 +1,223 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE_KEY = makePaneKey('tab-authority', '11111111-1111-4111-8111-111111111111')
const SECOND_PANE_KEY = makePaneKey('tab-authority-2', '22222222-2222-4222-8222-222222222222')
describe('AgentHookServer authority evidence', () => {
const servers: AgentHookServer[] = []
afterEach(() => {
for (const server of servers) {
server.stop()
}
servers.length = 0
})
it('freezes pre-listen commitments separately from current-runtime observations', async () => {
const server = new AgentHookServer()
servers.push(server)
const hydrated = {
paneKey: PANE_KEY,
launchToken: 'launch-before-restart',
tabId: 'tab-authority',
worktreeId: 'repo::before',
connectionId: 'ssh-target',
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
receivedAt: 100,
stateStartedAt: 100
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
await server.start()
const commitments = server.getHydratedAuthorityCommitments()
expect(commitments).toEqual([
{
paneKey: PANE_KEY,
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
tabId: 'tab-authority',
worktreeId: 'repo::before',
connectionId: 'ssh-target',
observedAt: 100
}
])
expect(Object.isFrozen(commitments)).toBe(true)
expect(Object.isFrozen(commitments[0])).toBe(true)
expect(server.getCurrentAuthorityObservations()).toEqual([])
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
server.ingestRemote(
{
paneKey: PANE_KEY,
launchToken: 'launch-after-restart',
tabId: 'tab-authority',
worktreeId: 'repo::after',
payload: { state: 'working', prompt: 'after', agentType: 'codex' }
},
'ssh-target'
)
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
expect(JSON.stringify(commitments)).not.toContain('launch-before-restart')
expect(server.getCurrentAuthorityObservations()).toEqual([
expect.objectContaining({
paneKey: PANE_KEY,
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
connectionId: 'ssh-target',
worktreeId: 'repo::after'
})
])
expect(JSON.stringify(server.getCurrentAuthorityObservations())).not.toContain(
'launch-after-restart'
)
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toBeNull()
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'current_runtime'
})
).toBeNull()
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toBeNull()
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'current_runtime'
})
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
server.ingestRemote(
{
paneKey: PANE_KEY,
launchToken: 'launch-before-restart',
tabId: 'tab-authority',
worktreeId: 'repo::current',
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
},
'ssh-target'
)
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
server.ingestRemote(
{
paneKey: SECOND_PANE_KEY,
launchToken: 'launch-before-restart',
tabId: 'tab-authority-2',
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
},
'ssh-target'
)
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toBeNull()
server.clearStatusEntriesForConnection('ssh-target')
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
expect(server.getCurrentAuthorityObservations()).toEqual([])
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
server.clearPaneState(PANE_KEY)
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toBeNull()
})
it('preserves hydrated authority when stale alias cleanup does not own the stable pane', async () => {
const server = new AgentHookServer()
servers.push(server)
const launchToken = 'launch-before-restart'
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
const hydrated = {
paneKey: PANE_KEY,
launchToken,
tabId: 'tab-authority',
worktreeId: 'repo::before',
connectionId: 'ssh-target',
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
receivedAt: 100,
stateStartedAt: 100
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
server.registerPaneKeyAlias('tab-authority:0', PANE_KEY, 'old-pty')
await server.start()
server.ingestRemote(
{
paneKey: PANE_KEY,
launchToken,
tabId: 'tab-authority',
worktreeId: 'repo::current',
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
},
'ssh-target'
)
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
expect(
server.attestCompatibilityAuthority({
paneKey: PANE_KEY,
launchTokenHash,
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
})
})
+219 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
existsSync,
mkdirSync,
@@ -57,6 +58,7 @@ const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
type Body = {
paneKey: string
launchToken?: string
tabId?: string
worktreeId?: string
env?: string
@@ -6254,7 +6256,10 @@ describe('Last-status persistence', () => {
try {
await postHookEvent(
server,
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' })
buildBody(
{ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' },
{ launchToken: 'launch-bearer-must-not-persist' }
)
)
// Synchronous flush via stop() captures the trailing-debounced write.
server.flushStatusPersistSync()
@@ -6269,11 +6274,148 @@ describe('Last-status persistence', () => {
stateStartedAt: expect.any(Number),
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
})
expect(file.entries[PANE].launchToken).toBeUndefined()
expect(file.entries[PANE].launchTokenHash).toBe(
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
)
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
} finally {
server.stop()
}
})
it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
const receivedAt = recentTs()
const launchToken = 'legacy-launch-bearer'
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
[PANE]: {
paneKey: PANE,
launchToken,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
receivedAt,
stateStartedAt: receivedAt,
payload: {
state: 'working',
prompt: 'legacy worker',
agentType: 'codex'
}
}
}
}),
'utf8'
)
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
expect(server.getHydratedAuthorityCommitments()).toEqual([
expect.objectContaining({ paneKey: PANE, launchTokenHash })
])
expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
const persisted = readFileSync(lastStatusPath(), 'utf8')
expect(persisted).not.toContain(launchToken)
expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash })
} finally {
server.stop()
}
const restartedServer = new AgentHookServer()
await restartedServer.start({ env: 'production', userDataPath })
try {
expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([
expect.objectContaining({
paneKey: PANE,
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
})
])
expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
} finally {
restartedServer.stop()
}
})
it('persists SSH authority through transient clears until explicit retirement', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
const receivedAt = recentTs()
const launchToken = 'retained-ssh-launch-bearer'
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
[PANE]: {
paneKey: PANE,
launchTokenHash,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: 'ssh-target',
receivedAt,
stateStartedAt: receivedAt,
payload: {
state: 'working',
prompt: 'retained SSH worker',
agentType: 'codex'
}
}
}
}),
'utf8'
)
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath })
first.clearStatusEntriesForConnection('ssh-target')
first.flushStatusPersistSync()
first.stop()
const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
expect(afterClear.entries).toEqual({})
expect(afterClear.authorityCommitments[PANE]).toMatchObject({
paneKey: PANE,
launchTokenHash,
connectionId: 'ssh-target'
})
const restored = new AgentHookServer()
await restored.start({ env: 'production', userDataPath })
expect(
restored.attestCompatibilityAuthority({
paneKey: PANE,
launchTokenHash,
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toEqual({ paneKey: PANE, source: 'hydrated_commitment' })
restored.retirePaneAuthority(PANE)
restored.flushStatusPersistSync()
restored.stop()
const retired = new AgentHookServer()
await retired.start({ env: 'production', userDataPath })
try {
expect(
retired.attestCompatibilityAuthority({
paneKey: PANE,
launchTokenHash,
connectionId: 'ssh-target',
terminalProvenance: 'restored'
})
).toBeNull()
} finally {
retired.stop()
}
})
it('persists and hydrates Pi session identity without creating status telemetry', async () => {
const firstServer = new AgentHookServer()
const firstRendererListener = vi.fn()
@@ -7640,6 +7782,82 @@ describe('AgentHookServer ingestRemote', () => {
})
describe('AgentHookServer ingestTerminalStatus', () => {
// Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row
// erased the session id from persisted rows and from headless `orca serve` — which serves these
// rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630).
it('keeps the cached provider session when an OSC status completes the turn', () => {
const server = new AgentHookServer()
const providerSession = { key: 'session_id' as const, id: 'claude-session-1' }
server.ingestRemote(
{
paneKey: PANE,
providerSession,
payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: { state: 'done', prompt: 'summarize the diff', agentType: 'claude' }
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession })
})
// Why: an OSC ping that names no agent — or the literal 'unknown', which
// resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it
// must not read as a mismatch. Missing this stripped the session from persisted and headless rows
// while the live renderer kept it, blanking mobile chat only after a restart (#10630).
it.each([undefined, 'unknown' as const])(
'keeps the cached provider session when an OSC status claims agentType %s',
(agentType) => {
const server = new AgentHookServer()
const providerSession = { key: 'session_id' as const, id: 'claude-session-1' }
server.ingestRemote(
{
paneKey: PANE,
providerSession,
payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: {
state: 'done',
prompt: 'summarize the diff',
...(agentType ? { agentType } : {})
}
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSession })
}
)
it('drops the cached provider session when an OSC status starts a new turn', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
providerSession: { key: 'session_id' as const, id: 'claude-session-1' },
payload: { state: 'done', prompt: 'first', agentType: 'claude' }
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: { state: 'working', prompt: 'second', agentType: 'claude' }
})
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
})
it('forwards runtime terminal status through the normal listener and snapshot path', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+383 -18
View File
@@ -85,6 +85,22 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
}
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
'launchToken' | 'promptInteractionKey'
> & {
launchTokenHash?: string
}
type PersistedAgentHookAuthorityCommitment = {
paneKey: string
launchTokenHash: string
connectionId: string | null
tabId?: string
worktreeId?: string
observedAt: number
}
export type AgentHookStatusChangeEntry = {
state: AgentStatusState
receivedAt: number
@@ -98,6 +114,20 @@ export type AgentHookProviderSessionIdentity = {
worktreeId?: string
}
export type AgentHookAuthorityEvidence = Readonly<{
paneKey: string
launchTokenHash: string
connectionId: string | null
tabId?: string
worktreeId?: string
observedAt: number
}>
export type AgentHookAuthorityAttestation = Readonly<{
paneKey: string
source: 'current_hook' | 'hydrated_commitment'
}>
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
@@ -134,7 +164,8 @@ export const PANE_KEY_ALIASES_MAX = 1024
type LastStatusFile = {
version: number
entries: Record<string, EnrichedAgentHookEventPayload>
entries: Record<string, PersistedAgentHookEventPayload>
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
}
type AgentPromptSentDedupeEntry = {
@@ -257,7 +288,6 @@ function sanitizeHydratedEntry(
}
return {
paneKey,
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
@@ -274,6 +304,63 @@ function sanitizeHydratedEntry(
}
}
function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
if (typeof rawEntry !== 'object' || rawEntry === null) {
return null
}
const record = rawEntry as Record<string, unknown>
const launchTokenHash =
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
if (/^[a-f0-9]{64}$/.test(launchTokenHash)) {
return launchTokenHash
}
const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : ''
return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null
}
function sanitizePersistedAuthorityCommitment(
paneKey: string,
value: unknown
): AgentHookAuthorityEvidence | null {
if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) {
return null
}
const record = value as Record<string, unknown>
const launchTokenHash =
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
const connectionId = record.connectionId
const observedAt = record.observedAt
if (
!/^[a-f0-9]{64}$/.test(launchTokenHash) ||
(connectionId !== null && typeof connectionId !== 'string') ||
typeof observedAt !== 'number' ||
!Number.isFinite(observedAt)
) {
return null
}
return Object.freeze({
paneKey,
launchTokenHash,
connectionId,
...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}),
...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}),
observedAt
})
}
function authorityCommitmentsMatch(
left: AgentHookAuthorityEvidence,
right: AgentHookAuthorityEvidence
): boolean {
return (
left.paneKey === right.paneKey &&
left.launchTokenHash === right.launchTokenHash &&
left.connectionId === right.connectionId &&
left.tabId === right.tabId &&
left.worktreeId === right.worktreeId
)
}
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
return {
paneKey: entry.paneKey,
@@ -485,6 +572,11 @@ export class AgentHookServer {
private state: HookListenerState = createHookListenerState()
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
private runtimeObservedStatusPaneKeys = new Set<string>()
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
private hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
private persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
@@ -568,6 +660,53 @@ export class AgentHookServer {
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
}
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
return this.hydratedAuthorityCommitments
}
getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] {
return Object.freeze(
Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry }))
)
}
attestCompatibilityAuthority(candidate: {
paneKey: string
launchTokenHash: string
connectionId: string | null
terminalProvenance: 'current_runtime' | 'restored'
}): AgentHookAuthorityAttestation | null {
const paneKey = this.resolvePaneKeyAlias(candidate.paneKey)
const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean =>
entry.launchTokenHash === candidate.launchTokenHash &&
entry.connectionId === candidate.connectionId
const commitments = this.hydratedAuthorityCommitments.filter(
(entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry)
)
const current = Array.from(this.currentAuthorityObservations.values())
const observations = current.filter(matchesCandidate)
const paneObservations = current.filter(
(entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey
)
const hasUniqueCurrentObservation =
observations.length === 1 &&
paneObservations.length === 1 &&
this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey
if (candidate.terminalProvenance === 'current_runtime') {
return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null
}
if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) {
return null
}
if (observations.length === 0 && paneObservations.length === 0) {
return Object.freeze({ paneKey, source: 'hydrated_commitment' })
}
if (!hasUniqueCurrentObservation) {
return null
}
return Object.freeze({ paneKey, source: 'current_hook' })
}
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
@@ -1307,9 +1446,40 @@ export class AgentHookServer {
tabId: owner?.tabId
})
}
const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
if (hydratedLaunchTokenHash) {
this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash)
}
const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey)
if (persistedAuthority) {
const owner = parsePaneKey(toPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.set(
toPaneKey,
Object.freeze({
...persistedAuthority,
paneKey: toPaneKey,
...(owner?.tabId ? { tabId: owner.tabId } : {})
})
)
}
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
}
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
if (authorityObservation) {
const owner = parsePaneKey(toPaneKey)
this.currentAuthorityObservations.delete(previousOwnerPaneKey)
this.currentAuthorityObservations.set(
toPaneKey,
Object.freeze({
...authorityObservation,
paneKey: toPaneKey,
tabId: owner?.tabId
})
)
}
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
if (promptDedupe !== undefined) {
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
@@ -1327,7 +1497,7 @@ export class AgentHookServer {
this.boundPaneKeyAliases()
this.closedAgentStatusPaneKeys.delete(toPaneKey)
this.notifyPaneKeyAliasPersistenceListener()
if (hadStatus) {
if (hadStatus || persistedAuthority) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@@ -1345,6 +1515,7 @@ export class AgentHookServer {
aliasChanged = true
}
}
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
@@ -1352,12 +1523,13 @@ export class AgentHookServer {
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
this.runtimeObservedStatusPaneKeys.delete(key)
this.currentAuthorityObservations.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (hadStatus) {
if (hadStatus || authorityChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@@ -1372,11 +1544,19 @@ export class AgentHookServer {
const clearedStatusPaneKeys = new Set<string>()
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
if (entry.ptyId === ptyId) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
const shouldClearStablePaneKey =
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
const revokedPaneKeys = new Set([legacyPaneKey])
if (shouldClearStablePaneKey) {
revokedPaneKeys.add(entry.stablePaneKey)
}
if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) {
statusChanged = true
}
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
statusChanged = true
clearedStatusPaneKeys.add(entry.stablePaneKey)
@@ -1385,6 +1565,7 @@ export class AgentHookServer {
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.currentAuthorityObservations.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
}
aliasChanged = true
@@ -1406,6 +1587,27 @@ export class AgentHookServer {
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
}
private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean {
let changed = false
for (const commitment of this.hydratedAuthorityCommitments) {
if (
paneKeys.has(commitment.paneKey) ||
paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey))
) {
this.revokedHydratedAuthorityCommitments.add(commitment)
changed = true
}
}
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed
changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed
}
return changed
}
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
@@ -1469,12 +1671,31 @@ export class AgentHookServer {
) {
return
}
// Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is
// never evidence that the session ended — yet overwriting the row dropped the cached identity.
// That erased it from persisted rows (lost across restart) and from headless `orca serve`, which
// serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630).
// A new turn after `done` still starts clean so a reused pane cannot inherit a finished session.
// Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an
// omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so
// it must not be read as a mismatch and strip the session the renderer would have kept.
const claimedAgentType =
event.payload.agentType && event.payload.agentType !== 'unknown'
? event.payload.agentType
: undefined
const preservedProviderSession =
previous?.providerSession &&
(claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) &&
(previous.payload.state !== 'done' || event.payload.state === 'done')
? previous.providerSession
: undefined
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
this.applyNormalizedStatus({
paneKey,
tabId,
worktreeId,
connectionId,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: event.payload
})
}
@@ -1608,6 +1829,7 @@ export class AgentHookServer {
isReplay: envelope.isReplay === true ? true : undefined,
payload: normalizedPayload
}
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(event)
}
@@ -1638,6 +1860,7 @@ export class AgentHookServer {
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
res.writeHead(404)
@@ -1679,6 +1902,7 @@ export class AgentHookServer {
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
this.recordCurrentAuthorityObservation(normalized)
const enriched = this.applyNormalizedStatus(normalized)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
@@ -1745,6 +1969,11 @@ export class AgentHookServer {
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
this.hydratedAuthorityCommitments = Object.freeze([])
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
this.revokedHydratedAuthorityCommitments = new WeakSet()
this.currentAuthorityObservations.clear()
this.promptSentDedupeByPaneKey.clear()
this.closedAgentStatusTabIds.clear()
this.closedAgentStatusPaneKeys.clear()
@@ -1756,7 +1985,7 @@ export class AgentHookServer {
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
if (!this.deleteStatusEntry(paneKey)) {
if (!this.deleteStatusEntry(paneKey, { preserveAuthority: true })) {
return
}
this.scheduleStatusPersist()
@@ -1781,7 +2010,7 @@ export class AgentHookServer {
if (entry.connectionId !== normalizedConnectionId) {
continue
}
const deleted = this.deleteStatusEntry(paneKey)
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (deleted) {
statusChanged = true
if (deleted.payload.agentType === 'codex') {
@@ -1791,6 +2020,11 @@ export class AgentHookServer {
}
}
}
for (const [paneKey, evidence] of this.currentAuthorityObservations) {
if (evidence.connectionId === normalizedConnectionId) {
this.currentAuthorityObservations.delete(paneKey)
}
}
if (statusChanged) {
// Why: persist/notify once — one disconnect can own many panes.
this.scheduleStatusPersist()
@@ -1804,7 +2038,10 @@ export class AgentHookServer {
})
}
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
private deleteStatusEntry(
paneKey: string,
options?: { preserveAuthority?: boolean }
): EnrichedAgentHookEventPayload | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
@@ -1813,9 +2050,14 @@ export class AgentHookServer {
return null
}
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
if (!options?.preserveAuthority) {
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
}
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.currentAuthorityObservations.delete(resolvedPaneKey)
if (existing.payload.state === 'done') {
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
}
@@ -1860,6 +2102,11 @@ export class AgentHookServer {
paneKeysToClear.add(paneKey)
}
}
for (const commitment of this.hydratedAuthorityCommitments) {
if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) {
paneKeysToClear.add(commitment.paneKey)
}
}
let aliasChanged = false
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
@@ -1873,6 +2120,7 @@ export class AgentHookServer {
aliasChanged = true
}
}
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear)
let statusChanged = false
for (const paneKey of paneKeysToClear) {
@@ -1883,12 +2131,13 @@ export class AgentHookServer {
this.clearCodexSubagentPoll(paneKey)
clearPaneCacheState(this.state, paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.currentAuthorityObservations.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (statusChanged) {
if (statusChanged || authorityChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@@ -1896,25 +2145,31 @@ export class AgentHookServer {
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneKeys = new Set([paneKey, resolvedPaneKey])
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
paneKeys.add(legacyPaneKey)
paneKeys.add(stablePaneKey.stablePaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
if (clearedAlias) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (hadStatus) {
if (hadStatus || authorityChanged) {
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
@@ -2052,6 +2307,8 @@ export class AgentHookServer {
}
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
this.state.lastStatusByPaneKey.clear()
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
let raw: string
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
@@ -2090,6 +2347,7 @@ export class AgentHookServer {
let hydrated = 0
let dropped = 0
let prunedLegacyClaudeSubagents = 0
let scrubbedLegacyLaunchTokens = 0
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
for (const [paneKey, rawEntry] of Object.entries(entries)) {
@@ -2100,6 +2358,21 @@ export class AgentHookServer {
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
if (entry && entry.receivedAt >= ttlCutoff) {
const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry)
if (launchTokenHash) {
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash)
const evidence = this.toAuthorityEvidence(entry, launchTokenHash)
if (evidence) {
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence)
}
}
if (
typeof rawResolvedEntry === 'object' &&
rawResolvedEntry !== null &&
typeof (rawResolvedEntry as Record<string, unknown>).launchToken === 'string'
) {
scrubbedLegacyLaunchTokens += 1
}
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
if (hydratedPayload !== entry.payload) {
prunedLegacyClaudeSubagents +=
@@ -2130,13 +2403,30 @@ export class AgentHookServer {
dropped += 1
}
}
for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment)
if (!commitment || commitment.observedAt < ttlCutoff) {
dropped += 1
continue
}
const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey)
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
dropped += 1
continue
}
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment)
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash)
}
if (dropped > 0) {
console.warn(
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
)
}
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) {
// Why: persist load-time pruning and bearer scrubbing once.
this.runStatusPersist()
} else if (hydrated > 0) {
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
@@ -2144,17 +2434,92 @@ export class AgentHookServer {
}
}
private captureHydratedAuthorityCommitments(): void {
this.revokedHydratedAuthorityCommitments = new WeakSet()
for (const entry of this.state.lastStatusByPaneKey.values()) {
const evidence = this.toAuthorityEvidence(
entry as EnrichedAgentHookEventPayload,
this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey)
)
if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) {
this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence)
}
}
this.hydratedAuthorityCommitments = Object.freeze(
Array.from(this.persistedAuthorityCommitmentsByPaneKey.values())
)
}
private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void {
const evidence = this.toAuthorityEvidence(payload)
if (evidence) {
this.currentAuthorityObservations.set(evidence.paneKey, evidence)
this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence)
this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash)
}
}
private toAuthorityEvidence(
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
launchTokenHashOverride?: string
): AgentHookAuthorityEvidence | null {
const launchToken = payload.launchToken?.trim()
const launchTokenHash =
launchTokenHashOverride ??
(launchToken ? createHash('sha256').update(launchToken).digest('hex') : null)
if (!launchTokenHash) {
return null
}
return Object.freeze({
paneKey: payload.paneKey,
launchTokenHash,
connectionId: payload.connectionId,
...(payload.tabId ? { tabId: payload.tabId } : {}),
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now()
})
}
private serializeStatusFile(): string {
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
const entries: Record<string, PersistedAgentHookEventPayload> = {}
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
const conflictedCommitments = new Set<string>()
for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) {
authorityCommitments[paneKey] = { ...commitment }
}
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
// Why: never persist invalid keys (matches the hydrate-path invariant).
if (!isValidPaneKey(paneKey)) {
continue
}
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
const {
promptInteractionKey: _promptInteractionKey,
launchToken,
...persistedPayload
} = payload as EnrichedAgentHookEventPayload
const launchTokenHash = launchToken?.trim()
? createHash('sha256').update(launchToken.trim()).digest('hex')
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
entries[paneKey] = {
...persistedPayload,
...(launchTokenHash ? { launchTokenHash } : {})
}
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
if (commitment && !conflictedCommitments.has(paneKey)) {
const existing = authorityCommitments[paneKey]
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
delete authorityCommitments[paneKey]
conflictedCommitments.add(paneKey)
} else {
authorityCommitments[paneKey] = { ...commitment }
}
}
}
const file: LastStatusFile = {
version: LAST_STATUS_FILE_VERSION,
entries,
authorityCommitments
}
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
return JSON.stringify(file)
}
@@ -135,6 +135,28 @@ describe('runtime AI Vault session scanner', () => {
)
})
it('keeps a repinned account home instead of stripping it', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: {
useRealCodexHome: false,
substituteCodexHome: '/data/orca/codex-accounts/account-2/home'
}
})
await expect(
prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', {
agent: 'codex',
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
codexHome: '/managed',
executionHostId: 'runtime:env-1'
})
).resolves.toEqual({
useRealCodexHome: false,
substituteCodexHome: '/data/orca/codex-accounts/account-2/home'
})
})
it('fails retryably when runtime preparation returns an invalid result', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} })
+6 -1
View File
@@ -105,7 +105,12 @@ const aiVaultListResultSchema = z.object({
scannedAt: z.string()
})
const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() })
// Why: zod strips unknown keys, so the repin home must be declared or the
// parent would silently drop it and resume under the wrong account's home.
const aiVaultPrepareSessionResumeResultSchema = z.object({
useRealCodexHome: z.boolean(),
substituteCodexHome: z.string().optional()
})
export function getSavedRuntimeAiVaultHostInfos(
userDataPath: string
@@ -0,0 +1,44 @@
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({
discoverOpenCodeSessionsMock: vi.fn(),
listOpenCodeDatabasesMock: vi.fn()
}))
vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({
discoverOpenCodeSessions: discoverOpenCodeSessionsMock
}))
vi.mock('../opencode-usage/scanner', () => ({
listOpenCodeDatabases: listOpenCodeDatabasesMock
}))
describe('opencodeDiscoveries', () => {
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
})
it('discovers local storage from the OpenCode XDG data directory', async () => {
vi.stubEnv('XDG_DATA_HOME', '/xdg/data')
vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config')
listOpenCodeDatabasesMock.mockResolvedValue([])
discoverOpenCodeSessionsMock.mockResolvedValue({
agent: 'opencode',
rootDir: '/xdg/data/opencode/storage',
files: []
})
const issues = []
await Promise.all(opencodeDiscoveries({}, [], 25, issues))
expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith({
storageDir: join('/xdg/data', 'opencode', 'storage'),
dbPaths: [],
limitPerAgent: 25,
issues
})
})
})
@@ -1,16 +1,11 @@
import { readdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
export function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
@@ -33,7 +28,7 @@ function opencodeStorageDirs(
wslHomeDirs: readonly string[]
): string[] {
return [
options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR,
options.opencodeStorageDir ?? resolveOpenCodeStorageDirectory(),
...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage'))
]
}
+90
View File
@@ -770,4 +770,94 @@ describe('setupGuestShortcutForwarding', () => {
expect(nextDownPreventDefault).not.toHaveBeenCalled()
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:openQuickOpen')
})
// A floating-workspace-owned guest routes close/index chords to the floating-scoped
// IPC *carrying its source id*; a main-owned guest keeps main routing.
describe('source-aware close/index routing', () => {
const closeInput = { code: 'KeyW', key: 'w' }
// workspace.selectByIndex default is Mod+1; tab.selectByIndex default is Ctrl+1 (darwin) / Alt+1 (other).
const workspaceIndexInput = { code: 'Digit1', key: '1' }
const tabIndexInput =
process.platform === 'darwin'
? { code: 'Digit1', key: '1', control: true, meta: false }
: { code: 'Digit1', key: '1', alt: true, control: false }
it('routes tab.close to ui:closeFloatingItem with the source id for a floating guest', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
resolveWorktreeId: () => 'global-floating-terminal'
})
const preventDefault = triggerBeforeInput(closeInput)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeFloatingItem', {
sourceId: browserTabId
})
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeActiveTab')
})
it('routes workspace/tab index chords to ui:selectFloatingIndex for a floating guest', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
resolveWorktreeId: () => 'global-floating-terminal'
})
triggerBeforeInput(workspaceIndexInput)
triggerBeforeInput(tabIndexInput)
expect(rendererSendMock).toHaveBeenCalledWith('ui:selectFloatingIndex', { index: 0 })
expect(rendererSendMock).toHaveBeenCalledTimes(2)
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0)
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:jumpToTabIndex', 0)
})
it('keeps main routing for a non-floating guest', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
resolveWorktreeId: () => 'some-worktree'
})
triggerBeforeInput(closeInput)
triggerBeforeInput(workspaceIndexInput)
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
expect(rendererSendMock).toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0)
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:selectFloatingIndex', expect.anything())
})
it('keeps main routing when no worktree resolver is provided', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer()
})
triggerBeforeInput(closeInput)
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
})
it('drops auto-repeat index chords at the source for a floating guest', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
resolveWorktreeId: () => 'global-floating-terminal'
})
const preventDefault = triggerBeforeInput({ ...workspaceIndexInput, isAutoRepeat: true })
expect(preventDefault).not.toHaveBeenCalled()
expect(rendererSendMock).not.toHaveBeenCalled()
})
})
})
+22 -4
View File
@@ -14,6 +14,7 @@ import {
} from '../../shared/window-shortcut-policy'
import { readGuestNavigationState } from './browser-guest-navigation-state'
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
import {
ModifierDoubleTapDetector,
@@ -261,6 +262,8 @@ export function setupGuestShortcutForwarding(args: {
shouldForwardDictationShortcut?: ShouldForwardDictationShortcut
isMobileEmulatorEnabled?: IsMobileEmulatorEnabled
getKeybindings?: () => KeybindingOverrides | undefined
// Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip.
resolveWorktreeId?: (browserTabId: string) => string | null
}): () => void {
const {
browserTabId,
@@ -268,7 +271,8 @@ export function setupGuestShortcutForwarding(args: {
resolveRenderer,
shouldForwardDictationShortcut,
isMobileEmulatorEnabled,
getKeybindings
getKeybindings,
resolveWorktreeId
} = args
let ctrlTabSwitching = false
const doubleTapDetector = new ModifierDoubleTapDetector()
@@ -364,6 +368,8 @@ export function setupGuestShortcutForwarding(args: {
if (!renderer) {
return false
}
// Why: floating-panel guests route close/index chords to the panel (carrying their source id) so they hit the floating workspace, not the main tab strip.
const isFloatingGuest = resolveWorktreeId?.(browserTabId) === FLOATING_TERMINAL_WORKTREE_ID
if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) {
renderer.send('ui:newBrowserTab')
} else if (
@@ -400,7 +406,11 @@ export function setupGuestShortcutForwarding(args: {
// Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly.
renderer.send('ui:browserHistoryNavigate', 'forward')
} else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) {
renderer.send('ui:closeActiveTab')
if (isFloatingGuest) {
renderer.send('ui:closeFloatingItem', { sourceId: browserTabId })
} else {
renderer.send('ui:closeActiveTab')
}
} else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) {
renderer.send('ui:switchTab', 1)
} else if (
@@ -424,9 +434,17 @@ export function setupGuestShortcutForwarding(args: {
} else if (action?.type === 'forceReload') {
renderer.reloadIgnoringCache()
} else if (action?.type === 'jumpToWorktreeIndex') {
renderer.send('ui:jumpToWorktreeIndex', action.index)
if (isFloatingGuest) {
renderer.send('ui:selectFloatingIndex', { index: action.index })
} else {
renderer.send('ui:jumpToWorktreeIndex', action.index)
}
} else if (action?.type === 'jumpToTabIndex') {
renderer.send('ui:jumpToTabIndex', action.index)
if (isFloatingGuest) {
renderer.send('ui:selectFloatingIndex', { index: action.index })
} else {
renderer.send('ui:jumpToTabIndex', action.index)
}
} else if (action?.type === 'dictationKeyDown') {
if (!shouldForwardDictationShortcut?.()) {
return false
+2 -1
View File
@@ -1714,7 +1714,8 @@ export class BrowserManager {
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false,
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
getKeybindings: () => this.settingsResolver?.().keybindings
getKeybindings: () => this.settingsResolver?.().keybindings,
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null
})
)
}
@@ -11,4 +11,41 @@ describe('packaged Windows CLI launcher asset', () => {
expect(launcher).toContain('orca.cmd cannot safely forward orchestration message bodies')
expect(launcher).not.toContain('"%ELECTRON%" "%CLI%" %*')
})
it('marks the packaged child and propagates its exact exit status', () => {
const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const source = readFileSync(sourcePath, 'utf8')
expect(source).toContain(
'startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";'
)
expect(source).toContain('Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"')
expect(source).toContain('? "orca-ide"')
expect(source).toContain(': "orca";')
expect(source).toContain('child.WaitForExit();')
expect(source).toContain('return child.ExitCode;')
})
it('requires exact updater process and executable release proof', () => {
const includePath = join(process.cwd(), 'config', 'nsis', 'daemon-host-uninstall.nsh')
const source = readFileSync(includePath, 'utf8')
expect(source).toContain('!macro customCheckAppRunning')
expect(source).toContain('!include "getProcessInfo.nsh"')
expect(source).toContain('Var pid')
expect(source).toContain('[String]::Equals')
expect(source).toContain('"$INSTDIR\\Orca.exe" "$INSTDIR\\resources\\bin\\orca.exe"')
expect(source).toContain('CreationDate=[string]$$parent.CreationDate')
expect(source).toContain("'ProcessId=' + $$proof.ProcessId")
expect(source).toContain('[IO.FileShare]::None')
expect(source).toContain('$$released=$$false')
expect(source).toContain('$$released=$$true')
expect(source).not.toMatch(/(?<!\$)(?:\$\$)*\$(?:false|true)\b/)
expect(source).toContain(
' ${else}\n !insertmacro IS_POWERSHELL_AVAILABLE\n !insertmacro _CHECK_APP_RUNNING\n ${endIf}'
)
expect(source).toContain('SetErrorLevel 2')
expect(source).toContain('Quit')
expect(source).not.toContain('taskkill /IM Orca.exe')
})
})
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
existsSync,
linkSync,
mkdtempSync,
mkdirSync,
readFileSync,
@@ -9,7 +10,11 @@ import {
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutFileAliases
} from '../ai-vault/codex-session-root-dedup'
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
describe('prepareLegacySharedCodexSessionResume', () => {
@@ -117,6 +122,15 @@ describe('prepareLegacySharedCodexSessionResume', () => {
}
)
it('still materializes a legacy resume when an account selection exists', async () => {
const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), {
...options(),
getSelectedHostAccountCodexHomePath: () => join(root, 'codex-accounts', 'account-1', 'home')
})
expect(result).toEqual({ useRealCodexHome: true })
})
function prepare() {
return prepareLegacySharedCodexSessionResume(legacyArgs(), options())
}
@@ -143,6 +157,174 @@ describe('prepareLegacySharedCodexSessionResume', () => {
}
})
// Why: the session bridge hardlinks each rollout into every per-account home
// and vault dedup keeps the lexicographically-smallest alias, so a session
// recorded by the selected account can surface under a peer account's home.
describe('per-account resume repin', () => {
let root: string
// Deliberately ordered so the SELECTED account loses the path tie-break.
let peerHome: string
let selectedHome: string
let recordedRolloutPath: string
let bridgedRolloutPath: string
const rolloutRelativePath = join(
'sessions',
'2026',
'07',
'20',
'rollout-2026-07-20T10-00-00-abcdef00-1234-4321-9999-cafecafecafe.jsonl'
)
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-codex-account-repin-'))
peerHome = join(root, 'codex-accounts', '11111111-aaaa-4aaa-8aaa-111111111111', 'home')
selectedHome = join(root, 'codex-accounts', '99999999-bbbb-4bbb-8bbb-999999999999', 'home')
recordedRolloutPath = join(selectedHome, rolloutRelativePath)
bridgedRolloutPath = join(peerHome, rolloutRelativePath)
mkdirSync(dirname(recordedRolloutPath), { recursive: true })
writeFileSync(recordedRolloutPath, '{"type":"session_meta"}\n', 'utf-8')
mkdirSync(dirname(bridgedRolloutPath), { recursive: true })
linkSync(recordedRolloutPath, bridgedRolloutPath)
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it.each([['recorded rollout first'], ['bridged alias first']])(
'repins the surviving vault row to the selected account, discovered %s',
async (order) => {
const recorded = rolloutCandidate(recordedRolloutPath, selectedHome)
const bridged = rolloutCandidate(bridgedRolloutPath, peerHome)
const survivors = dedupeCodexRolloutFileAliases(
order === 'recorded rollout first' ? [recorded, bridged] : [bridged, recorded],
rolloutCandidateAccessors
)
// The dedup pick is order-independent: the peer's alias wins the tie-break.
expect(survivors).toEqual([bridged])
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: bridged.filePath,
codexHome: peerHome,
executionHostId: 'local'
},
repinOptions()
)
expect(result).toEqual({ useRealCodexHome: false, substituteCodexHome: selectedHome })
}
)
it('keeps the home of a row that already names the selected account', async () => {
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: recordedRolloutPath,
codexHome: selectedHome,
executionHostId: 'local'
},
repinOptions()
)
expect(result).toEqual({ useRealCodexHome: false })
})
it('declines while the system default is selected', async () => {
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: bridgedRolloutPath,
codexHome: peerHome,
executionHostId: 'local'
},
{ ...repinOptions(), getSelectedHostAccountCodexHomePath: () => null }
)
expect(result).toEqual({ useRealCodexHome: false })
})
it('declines while the bridge has not yet linked the rollout into the selected home', async () => {
const unbridgedSelectedHome = join(
root,
'codex-accounts',
'ffffffff-cccc-4ccc-8ccc-ffffffffffff',
'home'
)
mkdirSync(unbridgedSelectedHome, { recursive: true })
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: bridgedRolloutPath,
codexHome: peerHome,
executionHostId: 'local'
},
{ ...repinOptions(), getSelectedHostAccountCodexHomePath: () => unbridgedSelectedHome }
)
expect(result).toEqual({ useRealCodexHome: false })
})
it('declines a session owned by another host', async () => {
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: bridgedRolloutPath,
codexHome: peerHome,
executionHostId: 'ssh:server-1' as 'local'
},
repinOptions()
)
expect(result).toEqual({ useRealCodexHome: false })
})
it('declines a transcript outside the dated rollout layout', async () => {
const straySessionPath = join(peerHome, 'sessions', 'stray.jsonl')
writeFileSync(straySessionPath, '{"type":"session_meta"}\n', 'utf-8')
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: straySessionPath,
codexHome: peerHome,
executionHostId: 'local'
},
repinOptions()
)
expect(result).toEqual({ useRealCodexHome: false })
})
function repinOptions() {
return {
isHostSystemDefaultRealHome: () => false,
getSelectedHostAccountCodexHomePath: () => selectedHome
}
}
function rolloutCandidate(filePath: string, codexHome: string) {
const stat = statSync(filePath)
return {
filePath,
codexHome,
file: { dev: stat.dev, ino: stat.ino, nlink: stat.nlink }
}
}
})
const rolloutCandidateAccessors = {
isCodex: () => true,
getFilePath: (candidate: { filePath: string }) => candidate.filePath,
getCodexHome: (candidate: { codexHome: string }) => candidate.codexHome,
getHardlinkIdentity: (candidate: { file: { dev: number; ino: number; nlink: number } }) =>
codexRolloutHardlinkIdentity(candidate.file)
}
function rootPlaceholder(): string {
return '__ROOT__'
}
+49 -2
View File
@@ -6,6 +6,7 @@ import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import { isPerAccountManagedCodexHome } from '../../shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import {
@@ -27,10 +28,15 @@ export async function prepareLegacySharedCodexSessionResume(
args: AiVaultPrepareSessionResumeArgs,
options: {
isHostSystemDefaultRealHome: () => boolean
getSelectedHostAccountCodexHomePath?: () => string | null
legacyCodexHomePath?: string
systemCodexHomePath?: string
}
): Promise<AiVaultPrepareSessionResumeResult> {
const substituteCodexHome = await resolveSelectedAccountCodexHomeForResume(args, options)
if (substituteCodexHome) {
return { useRealCodexHome: false, substituteCodexHome }
}
const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath)
const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot)
const managedSessionsRoot = join(legacyCodexHomePath, 'sessions')
@@ -46,7 +52,7 @@ export async function prepareLegacySharedCodexSessionResume(
const sourcePath = resolve(args.filePath)
const relativePath = relative(resolve(managedSessionsRoot), sourcePath)
if (!isLegacyRolloutRelativePath(relativePath)) {
if (!isDatedRolloutRelativePath(relativePath)) {
throw new Error(RETRYABLE_RESUME_ERROR)
}
const targetPath = join(paths.systemSessionsRoot, relativePath)
@@ -78,6 +84,47 @@ export async function prepareLegacySharedCodexSessionResume(
return { useRealCodexHome: true }
}
/**
* Repins a per-account resume to the selected account's home, or null to keep
* the session's own home.
*
* Why: the session bridge hardlinks each rollout into every per-account home,
* and vault dedup keeps the lexicographically-smallest alias — which names an
* arbitrary account. When the selected account's home holds the same rollout
* at the same sessions-relative path, resume must run under that account's
* credentials. Every uncertain branch (no selection, unbridged rollout, odd
* layout) declines, so resume degrades to today's behavior instead of failing.
*/
async function resolveSelectedAccountCodexHomeForResume(
args: AiVaultPrepareSessionResumeArgs,
options: { getSelectedHostAccountCodexHomePath?: () => string | null }
): Promise<string | null> {
if (
args.agent !== 'codex' ||
args.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
!args.codexHome ||
!isPerAccountManagedCodexHome(args.codexHome)
) {
return null
}
const selectedCodexHome = options.getSelectedHostAccountCodexHomePath?.() ?? null
if (!selectedCodexHome || sameRuntimePath(selectedCodexHome, args.codexHome)) {
return null
}
const relativePath = relative(resolve(join(args.codexHome, 'sessions')), resolve(args.filePath))
if (!isDatedRolloutRelativePath(relativePath)) {
return null
}
const candidatePath = join(selectedCodexHome, 'sessions', relativePath)
try {
const candidateStat = await lstat(candidatePath)
// Why: the bridge is async, so an unbridged rollout is a real state — decline rather than pin a home codex cannot resume from.
return candidateStat.isFile() && !candidateStat.isSymbolicLink() ? selectedCodexHome : null
} catch {
return null
}
}
async function materializeLegacyRollout(
sourcePath: string,
targetPath: string,
@@ -152,7 +199,7 @@ async function fileDigest(filePath: string): Promise<string> {
return hash.digest('hex')
}
function isLegacyRolloutRelativePath(relativePath: string): boolean {
function isDatedRolloutRelativePath(relativePath: string): boolean {
if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) {
return false
}
+49 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
import { getDaemonSocketPath } from './daemon-spawner'
@@ -328,6 +328,30 @@ describe('DaemonClient', () => {
})
describe('RPC', () => {
it('rejects an oversized request before installing a timer or writing', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as {
controlSocket: Socket
pendingRequests: Map<string, unknown>
}
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
const timerSpy = vi.spyOn(globalThis, 'setTimeout')
try {
await expect(
client.request('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
).rejects.toBeInstanceOf(NdjsonLineTooLongError)
expect(timerSpy).not.toHaveBeenCalled()
expect(writeSpy).not.toHaveBeenCalled()
expect(internals.pendingRequests.size).toBe(0)
} finally {
timerSpy.mockRestore()
writeSpy.mockRestore()
}
})
it('sends request and receives response', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
@@ -562,5 +586,29 @@ describe('DaemonClient', () => {
client = new DaemonClient({ socketPath, tokenPath })
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
})
it('reports a dropped delivery for an oversized payload without writing', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as { controlSocket: Socket }
const writeSpy = vi.spyOn(internals.controlSocket, 'write')
expect(client.notify('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })).toBe(false)
expect(writeSpy).not.toHaveBeenCalled()
})
it('reports a dropped delivery when the socket write throws', async () => {
await startMockDaemon()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const internals = client as unknown as { controlSocket: Socket }
vi.spyOn(internals.controlSocket, 'write').mockImplementation(() => {
throw new Error('EPIPE')
})
// Swallowed, not rethrown: a dead socket must not tear down the caller.
expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false)
})
})
})
+9 -3
View File
@@ -199,6 +199,7 @@ export class DaemonClient {
const id = `req-${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
const encoded = encodeNdjson(msg)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -212,7 +213,7 @@ export class DaemonClient {
timer
})
this.controlSocket!.write(encodeNdjson(msg))
this.controlSocket!.write(encoded)
})
}
@@ -224,8 +225,13 @@ export class DaemonClient {
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
this.controlSocket.write(encodeNdjson(msg))
return true
try {
this.controlSocket.write(encodeNdjson(msg))
return true
} catch {
// Notifications are best-effort; an oversized payload must not tear down the caller.
return false
}
}
onEvent(listener: (event: unknown) => void): () => void {
@@ -10,11 +10,13 @@ export type TerminalCheckpointFile = {
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
pendingEscapeTailAnsi?: string
cwd: string | null
cols: number
rows: number
modes: TerminalModes
scrollbackLines: number
lastTitle?: string
/** Ties this checkpoint to the output.log whose header carries the same
* generation. Absent on checkpoints written before incremental logs. */
generation?: number
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(29)
expect(PROTOCOL_VERSION).toBe(30)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -12,5 +12,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
})
})
@@ -4,6 +4,7 @@ import {
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
@@ -11,25 +12,27 @@ import {
} from './daemon-protocol-version'
describe('daemon protocol version', () => {
it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => {
expect(PROTOCOL_VERSION).toBe(29)
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(30)
expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30)
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11)
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 28 }, (_, index) => index + 1)
Array.from({ length: 29 }, (_, index) => index + 1)
)
})
it('withholds 2031-unsubscribe support from every preserved older daemon', () => {
it('withholds 2031-unsubscribe support only before its v29 boundary', () => {
// Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app
// update is the live hazard — it emits '2031-subscribe' with no way to retract it.
// The boundary must sit at 29, not merely "recent enough".
expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true)
expect(supportsMode2031UnsubscribeFact(29)).toBe(true)
expect(supportsMode2031UnsubscribeFact(28)).toBe(false)
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => version < 29)) {
expect(supportsMode2031UnsubscribeFact(version)).toBe(false)
}
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993).
export const PROTOCOL_VERSION = 29
// v30 transfers large cold-restore seeds across bounded NDJSON messages.
export const PROTOCOL_VERSION = 30
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
@@ -21,7 +22,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28
28, 29
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
@@ -206,7 +206,7 @@ describe('DaemonPtyAdapter history recovery', () => {
const originalCheckpoint = manager.checkpoint.bind(manager)
let malformedLog!: Buffer
vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => {
await originalCheckpoint(...args)
const result = await originalCheckpoint(...args)
const sessionDir = join(historyDir, getHistorySessionDirName(id))
const checkpoint = JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf-8'))
malformedLog = Buffer.concat([
@@ -217,6 +217,7 @@ describe('DaemonPtyAdapter history recovery', () => {
])
])
writeFileSync(join(sessionDir, 'output.log'), malformedLog)
return result
})
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
@@ -243,13 +244,12 @@ describe('DaemonPtyAdapter history recovery', () => {
let releaseCheckpoint!: () => void
let checkpointCalls = 0
vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => {
checkpointCalls++
if (checkpointCalls === 1) {
if (++checkpointCalls === 1) {
await new Promise<void>((resolve) => {
releaseCheckpoint = resolve
})
}
await originalCheckpoint(...args)
return originalCheckpoint(...args)
})
const shuttingDown = historyAdapter.shutdown(id, {
+172 -3
View File
@@ -16,10 +16,12 @@ import { HeadlessEmulator } from './headless-emulator'
import { getHistorySessionDirName } from './history-paths'
import type { HistoryReader } from './history-reader'
import type { SubprocessHandle } from './session'
import type { PendingOutputRecord } from './types'
import type { DaemonFileLog } from './daemon-file-log'
import type * as DaemonHealthModule from './daemon-health'
import { getDaemonSocketPath } from './daemon-spawner'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown')
@@ -1848,7 +1850,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
snapshot: null
}
})
const checkpoint = vi.fn(async () => {})
const checkpoint = vi.fn(async () => 'committed' as const)
const appendIncrements = vi.fn(async () => 'ok' as const)
const dispose = vi.fn(async () => {})
const disconnect = vi.fn()
@@ -1904,11 +1906,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
function makeCooldownHarness(takeResult: {
overflowed: boolean
appendResult?: 'ok' | 'needs-checkpoint'
checkpointResult?: 'committed' | 'retryable' | 'unavailable'
snapshotRecords?: PendingOutputRecord[]
}): CooldownInternals {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const request = vi.fn(async (_type: string, payload: Record<string, unknown>) => {
if (payload.includeSnapshot === true) {
return { records: [], seq: 2, overflowed: false, snapshot: { cols: 80, rows: 24 } }
return {
records: takeResult.snapshotRecords ?? [],
seq: 2,
overflowed: false,
snapshot: { cols: 80, rows: 24 }
}
}
return {
records: [{ kind: 'output', data: 'x' }],
@@ -1920,7 +1929,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const internals = historyAdapter as unknown as CooldownInternals
internals.client = { request, disconnect: vi.fn() }
internals.historyManager = {
checkpoint: vi.fn(async () => {}),
checkpoint: vi.fn(async () => takeResult.checkpointResult ?? 'committed'),
appendIncrements: vi.fn(async () => takeResult.appendResult ?? 'ok'),
dispose: vi.fn(async () => {})
}
@@ -1982,6 +1991,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
expect(internals.sessionsNeedingFullCheckpoint.has('capped')).toBe(false)
})
it('defers a teardown checkpoint that fails to serialize and drops its held tail', async () => {
const internals = makeCooldownHarness({
overflowed: false,
checkpointResult: 'retryable',
// Held shell-ready bytes ride out with the teardown snapshot (Session.prepareForFinalSnapshot).
snapshotRecords: [{ kind: 'output', data: 'held tail' }]
})
await expect(
internals.checkpointSessions(['sleeping'], { final: true, teardown: true })
).resolves.toEqual(new Set())
expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1)
expect(internals.sessionsNeedingFullCheckpoint.has('sleeping')).toBe(true)
// Why the tail must not be appended: the output this take drained went into the failed snapshot, so the tail
// would land at a contiguous seq over that hole and pass the log's gap detection.
expect(internals.historyManager.appendIncrements).not.toHaveBeenCalled()
expect(internals.lastFullCheckpointAt.has('sleeping')).toBe(false)
})
})
it('does not schedule a checkpoint timer until a session is dirty', async () => {
@@ -2253,6 +2282,146 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
it('uploads a large cold-restore seed in bounded protocol chunks', async () => {
const sessionId = 'chunked-cold-restore'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
const snapshotAnsi = `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}\r\nCHUNKED-SEED-MARKER`
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/chunked',
cols: 80,
rows: 24,
startedAt: '2026-07-25T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(
join(sessionDir, 'checkpoint.json'),
JSON.stringify({
snapshotAnsi,
scrollbackAnsi: '',
rehydrateSequences: '',
cwd: '/projects/chunked',
cols: 80,
rows: 24,
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: false
},
scrollbackLines: 0,
generation: 0,
checkpointedAt: '2026-07-25T10:00:00Z'
})
)
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const client = (
historyAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const requestSpy = vi.spyOn(client, 'request')
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore?.scrollback).toContain('CHUNKED-SEED-MARKER')
expect(requestSpy.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining([
'startHistorySeedTransfer',
'appendHistorySeedTransfer',
'finishHistorySeedTransfer',
'createOrAttach'
])
)
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
expect(createPayload).toMatchObject({
historySeedTransferId: expect.any(String)
})
expect(createPayload).not.toHaveProperty('historySeed')
await expect(historyAdapter.getBufferSnapshot(sessionId)).resolves.toMatchObject({
data: expect.stringContaining('CHUNKED-SEED-MARKER')
})
})
it('keeps large recovery renderer-only with a preserved legacy daemon', async () => {
await server.shutdown()
server = new DaemonServer({
socketPath,
tokenPath,
protocolVersion: 29,
spawnSubprocess: (opts) => {
lastSpawnOpts = opts
lastSubprocess = createMockSubprocess()
return lastSubprocess
}
})
await server.start()
const sessionId = 'legacy-large-cold-restore'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
const checkpointPath = join(sessionDir, 'checkpoint.json')
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/legacy',
cols: 80,
rows: 24,
startedAt: '2026-07-25T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(
checkpointPath,
JSON.stringify({
snapshotAnsi: `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}LEGACY-MARKER`,
scrollbackAnsi: '',
rehydrateSequences: '',
cwd: '/projects/legacy',
cols: 80,
rows: 24,
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: false
},
scrollbackLines: 0,
generation: 0,
checkpointedAt: '2026-07-25T10:00:00Z'
})
)
historyAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
protocolVersion: 29,
historyPath: historyDir
})
const client = (
historyAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const requestSpy = vi.spyOn(client, 'request')
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore?.scrollback).toContain('LEGACY-MARKER')
expect(requestSpy.mock.calls.map(([type]) => type)).not.toContain('startHistorySeedTransfer')
const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
expect(createPayload).not.toHaveProperty('historySeed')
expect(createPayload).not.toHaveProperty('historySeedTransferId')
expect(existsSync(checkpointPath)).toBe(true)
const managerInternals = historyAdapter.getHistoryManager()! as unknown as {
writers: Map<string, unknown>
}
expect(managerInternals.writers.has(sessionId)).toBe(false)
})
it('repairs legacy hostname UNC cwd for WSL spawn and cold-restore metadata', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+114 -24
View File
@@ -3,8 +3,13 @@ import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import { DaemonClient } from './client'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { HistoryManager, type HistoryRecoveryFreeze } from './history-manager'
import {
HistoryManager,
type HistoryCheckpointResult,
type HistoryRecoveryFreeze
} from './history-manager'
import { HistoryReader, type ColdRestoreInfo } from './history-reader'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
@@ -25,6 +30,7 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import {
isAgentSessionClaimedSpawnResult,
isAgentSessionOwnerBinding,
@@ -51,6 +57,12 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
import {
iterateTerminalHistorySeedChunks,
measureTerminalHistorySeed,
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
} from './terminal-history-seed-chunks'
import { NdjsonLineTooLongError } from './ndjson'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@@ -74,14 +86,6 @@ function takeRecoveryFreeze(
historyRecovery.freeze = null
return freeze
}
function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
// Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen.
return restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
}
function providerSequenceForSpawn(
result: CreateOrAttachResult
): PtySpawnResult['providerSequence'] {
@@ -412,7 +416,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const createOrAttach = (historySeed: string | null) => {
const requestCreateOrAttach = (
historySeed: string | undefined,
historySeedTransferId: string | undefined
) => {
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
@@ -433,6 +440,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
shellReadySupported,
...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}),
...(historySeed ? { historySeed } : {}),
...(historySeedTransferId ? { historySeedTransferId } : {}),
...(this.supportsStartupIngress && opts.startupIngress
? { startupIngress: opts.startupIngress }
: {}),
@@ -440,7 +448,65 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
}
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
const createOrAttach = async (
historySeedSegments: readonly string[] | null
): Promise<CreateOrAttachResult> => {
// Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call
// delivery failure must not force historySeeded=false on a retry that seeded successfully.
let historySeedUnavailable = false
const deliverSeedAndCreate = async (): Promise<CreateOrAttachResult> => {
if (!historySeedSegments || historySeedSegments.length === 0) {
return requestCreateOrAttach(undefined, undefined)
}
const metrics = measureTerminalHistorySeed(historySeedSegments)
if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) {
try {
return await requestCreateOrAttach(historySeedSegments.join(''), undefined)
} catch (error) {
if (!(error instanceof NdjsonLineTooLongError)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
}
if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) {
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
let transferId: string | undefined
try {
const started = await this.client.request<{ transferId: string }>(
'startHistorySeedTransfer',
metrics
)
transferId = started.transferId
let index = 0
for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) {
await this.client.request('appendHistorySeedTransfer', { transferId, index, data })
index += 1
}
await this.client.request('finishHistorySeedTransfer', { transferId })
} catch (error) {
if (transferId) {
await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {})
}
if (isDaemonGoneError(error)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
return requestCreateOrAttach(undefined, transferId)
}
const result = await deliverSeedAndCreate()
return historySeedUnavailable && result.historySeeded === undefined
? { ...result, historySeeded: false }
: result
}
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
const requestedSessionId = sessionId
if (
@@ -463,9 +529,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
historyRecovery.unreadableSessionId = null
historyRecovery.identityChanged = true
restoreInfo = null
scrollback = null
historySeedSegments = null
}
let result = await createOrAttach(scrollback)
let result = await createOrAttach(historySeedSegments)
await adoptSpawnResultSession(result)
// Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner.
this.clearSessionAwaitingDaemonRecovery(requestedSessionId)
@@ -528,8 +594,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
if (restoreInfo && scrollback) {
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
if (result.incarnationId) {
@@ -540,7 +606,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
effectiveRows = restoreInfo.rows
result = await createOrAttach(scrollback)
result = await createOrAttach(historySeedSegments)
await adoptSpawnResultSession(result)
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedRetryResult) {
@@ -565,7 +631,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
result.historySeeded === false
) {
restoreInfo = await detectColdRestore()
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
}
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
@@ -575,7 +641,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
const coldRestore = this.buildColdRestorePayload(restoreInfo)
const canReanchorHistory = !scrollback || result.historySeeded === true
const canReanchorHistory =
!historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
if (this.historyManager && !historyRecovery.identityChanged) {
const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId)
@@ -1661,7 +1728,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!this.supportsIncrementalCheckpoints) {
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
if (result.snapshot && this.historyManager) {
await this.historyManager.checkpoint(sessionId, result.snapshot)
const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot)
return checkpoint === 'retryable' ? 'deferred' : 'done'
}
return 'done'
}
@@ -1671,7 +1739,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown })
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: opts.teardown
})
if (checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
return 'done'
}
@@ -1687,7 +1761,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
if (checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
return 'done'
}
if (take.records.length === 0) {
@@ -1707,7 +1785,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
if (checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
}
return 'done'
}
@@ -1715,20 +1797,28 @@ export class DaemonPtyAdapter implements IPtyProvider {
private async takeSnapshotAndCheckpoint(
sessionId: string,
opts: { teardown: boolean }
): Promise<void> {
): Promise<HistoryCheckpointResult> {
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId,
includeSnapshot: true,
teardownSnapshot: opts.teardown
})
if (take?.snapshot && this.historyManager) {
await this.historyManager.checkpoint(sessionId, take.snapshot)
const checkpoint = await this.historyManager.checkpoint(sessionId, take.snapshot)
if (checkpoint !== 'committed') {
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
return checkpoint
}
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0) {
// Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
return 'committed'
}
return 'unavailable'
}
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.

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