fix: preserve live agent PTYs through graph hydration (#11789)

This commit is contained in:
OrcaWin 2026-08-03 11:11:14 -07:00 committed by GitHub
parent 128e3e335e
commit cd68a8b00c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3941 additions and 769 deletions

View File

@ -2114,31 +2114,47 @@
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
"layer": "renderer-state",
"layer": "cross-boundary",
"surfaces": [
"agent launch",
"workspace activation",
"sleep and hibernate restore",
"provider session dedupe",
"sidebar and mobile identity"
"sidebar and mobile identity",
"runtime-owned background PTY mount and remount"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the ownership/dedupe suite on main@1282f5c2d. Queued/pending resume-claim indexing, same-session and wrong-session hook proofs, and Electron repeat-activation coverage arrive with the pending stack (#7008).",
"coveredProviders": ["local", "daemon", "remote-runtime"],
"coverageNotes": "Renderer ownership/dedupe contracts cover provider-session claims. Local and daemon attach-only contracts prove an existing stable-pane owner is adopted without provider creation, while remote-runtime transport contracts preserve adopted ownership through cancellation. The Electron oracle covers a local macOS runtime and daemon with real agent, Setup, and unrelated-canary processes; SSH, WSL, paired-server, Linux, and Windows remain contract-only or unrun.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6800",
"https://github.com/stablyai/orca/pull/5240",
"https://github.com/stablyai/orca/pull/6411",
"https://github.com/stablyai/orca/pull/6833"
"https://github.com/stablyai/orca/pull/6833",
"https://github.com/stablyai/orca/pull/11789",
"https://github.com/stablyai/orca/pull/11819"
],
"invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, and reconnect code must not replay or resume a provider session id already owned, queued, pending, or live in that workspace.",
"oracle": "The current renderer-state slice asserts provider-session claim keys are owned by preserved active tabs, inactive split leaves, visible non-focused split groups, live records, quit records, worktree-sleep records, queued startup payloads, time-bounded resume bridge claims, and same-session live hook evidence; duplicates clear without launching a second resume command. The provider list is the risk scope, not proof that every provider has a live integration gate.",
"invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, mount, remount, and reconnect code must not replay or resume a provider session id already owned, queued, pending, live, or durably bound to a host PTY in that workspace. A renderer with missing projection state must adopt the exact runtime-owned PTY for the original tab and leaf rather than create a replacement.",
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/daemon/terminal-host.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/ipc/pty.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/renderer/src/lib/pane-manager/pane-fit.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
],
"testFiles": ["src/renderer/src/lib/resume-sleeping-agent-session.test.ts"],
"assertionRefs": [
{
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
@ -2148,6 +2164,42 @@
"active stable-pane records owned by preserved or visible panes are not resumed again",
"hibernated stable panes with cleared live PTY bindings are skipped"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"a completed runtime-owned stable pane is adopted with its original PTY and incarnation while renderer resume intent is stripped",
"an exact persisted owner is attach-only adopted when the runtime projection is missing",
"runtime and persisted stable-pane owner conflicts fail closed before provider creation"
]
},
{
"file": "src/renderer/src/lib/pane-manager/pane-fit.test.ts",
"assertions": [
"withheld hidden-window animation frames exhaust the bounded fit retry and release its continuation"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"same-generation explicit reattach drains the authoritative snapshot before immediate live bytes and ACKs their delivery credit"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"assertions": [
"paired-runtime stable-pane adoption reports reattach without fresh-spawn ownership",
"cancellation after a paired-runtime adoption cannot close the original owner"
]
},
{
"file": "tests/e2e/live-background-terminal-mount-authority.spec.ts",
"assertions": [
"first mount and renderer reload preserve exact agent and Setup handle, PTY, incarnation, tab, leaf, and PID identity",
"PID-specific keyboard input and output remain user-visible in both mounted panes",
"runtime inventory, renderer graph, persisted session, runtime epoch, and daemon PID converge without replacement or resume",
"the unrelated canary remains writable and receives no signal across target projection repair"
]
}
],
"evidenceRuns": [
@ -2162,30 +2214,32 @@
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "local renderer state test"
"p95Seconds": 180,
"scope": "focused renderer/main/provider contracts plus one isolated Electron mount-and-reload journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Registered after targeted tests were found; needs soak history before blocking promotion."
"evidence": "The renderer gate has prior local evidence; the stable-pane Electron oracle is new and needs CI soak before blocking promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests encode provider-session dedupe and ownership claims across active/inactive/visible split records, queued pendingStartupByTabId resume payloads, time-bounded runtime automaticAgentResumeClaimsByTabId bridge claims, live same-session hook evidence, wrong-session hook rejection, and a bounded queued-claim index over many records/tabs. Needs saved red/green artifact for the class-level replay invariant."
"evidence": "Renderer tests encode provider-session dedupe across active, inactive, queued, and live claims. The cross-boundary Electron oracle is constructed for byte-identical latest-main, candidate, and candidate-revert runs; record those three terminal results before promoting this gate."
},
"performanceBudget": {
"required": true,
"evidence": "Current state tests are cheap and assert queued pending-startup provider-session ids are indexed once per activation. PRs adding new ownership scans must show bounded work over records and no hidden-pane wake loop before blocking promotion."
"evidence": "Renderer state tests assert bounded provider-session indexing. Stable-pane adoption is a targeted owner lookup and attach-only call; focused contracts require no provider listing scan, fresh spawn callback, or repeated resume probe. The Electron oracle checks exact launch counts but is not a throughput benchmark."
},
"promotionCriteria": [
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
"Add bounded-work assertions for delayed hook/status ownership scans if those paths grow.",
"Attach red/green evidence that display/replay evidence alone cannot claim ownership."
"Attach red/green evidence that display/replay evidence alone cannot claim ownership.",
"Record byte-identical latest-main, candidate, and candidate-revert Electron results."
],
"knownGaps": [
"Providers listed on this gate are affected identity surfaces; the current executable command is renderer-state coverage, not live local/daemon/SSH/WSL/remote-runtime coverage.",
"Current command models live same-session and wrong-session hook evidence, but does not run the real hook timing through Electron.",
"Current command does not run a real workspace activation loop repeatedly through Electron."
"Providers listed on this gate are affected identity surfaces; live integration is limited to local macOS while daemon and remote-runtime adoption also have focused contracts.",
"The Electron oracle seeds the production hook-store contract instead of running an authenticated Codex hook end to end.",
"The live Electron topology is local macOS only; folder workspaces, SSH, WSL, paired headed/headless servers, Linux, and Windows are not exercised by that journey.",
"The oracle covers first activation and one renderer reload, not repeated soak activation or an installed-app update."
],
"demotionRule": "Demote or quarantine if failures are non-actionable or if a duplicate resume escape occurs outside the modeled matrix."
},
@ -7781,8 +7835,8 @@
"https://github.com/stablyai/orca/pull/5787",
"https://github.com/stablyai/orca/pull/8034"
],
"invariant": "After a renderer lifecycle reset (did-start-loading / render-process-gone / destroyed), no surviving PTY remains delivery-gated by pre-reset unacked bytes: main's in-flight counters and pending backlog equal the true state of the new page (zero in-flight, zero pending). Delivery then resumes only once the reloaded page's pty:data dispatcher re-registers and signals pty:rendererDispatcherReady; during the boot window before that handshake main holds all sends (data accrues losslessly in the capped pending backlog) so bytes cannot be dropped into a listener-less page and re-pin the gate. The hold itself cannot become a permanent freeze: a one-shot ~10s watchdog armed on each reset force-opens the gate (incrementing rendererDispatcherReadyForcedCount) if the handshake is lost, and the real handshake or a re-registration cancels it. The reset fires only for a main-frame load: did-start-loading also fires for in-page subframe loads (sandboxed srcDoc iframes in notebook HTML output), which are filtered out via isLoadingMainFrame() so a subframe load never clears accounting or holds the gate on the still-alive page. If a lifecycle-reset edge is missed entirely — a main-frame reload overlapped by an in-page subframe load emits no did-start-loading at all — a backstop still recovers: because the handshake is one-shot per page load, receiving pty:rendererDispatcherReady while the gate is already open proves a reset was missed (or the watchdog force-opened the gate), so the handler reconciles by clearing the stale accounting before re-opening. The renderer sends that handshake exactly once per page load, after its pty:data listener registers.",
"oracle": "Ingest more than 512 KB of PTY output with no renderer ACKs and assert the per-PTY gate closes (sends stop at the 512 KB high-water, remainder accrues as pending). Fire the registered did-start-loading listener and assert rendererInFlightChars and pendingChars are zero and the new diagnostics record the reset (rendererLifecycleResetCount 1, lastLifecycleResetClearedChars 512 KB). Then, before any dispatcher-ready handshake, ingest another chunk and assert it is NOT sent and NOT counted in-flight (held for the boot window, accruing in pending). Finally fire the pty:rendererDispatcherReady handshake and assert the held chunk is delivered to the renderer. Counters-zero without proving both the boot-window hold and that delivery resumes is insufficient. Additional cases prove the boot-window hold also covers the interactive direct-send fast path (input-primed keystroke echo is held, not sent, until the handshake) and that the self-heal watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) when no handshake arrives, while a timely handshake cancels the watchdog and leaves no orphaned timer. A further case fires did-start-loading with isLoadingMainFrame() false (a subframe/iframe load) and asserts accounting is untouched (rendererLifecycleResetCount stays 0, pending preserved, ready stays true) and delivery still drains on ACK — proving an in-page iframe load cannot trigger a spurious freeze. A backstop case saturates the gate, then fires pty:rendererDispatcherReady while the gate is still open (ready true) with no preceding reset — modeling a missed lifecycle edge — and asserts the handler reconciles: in-flight and pending clear, rendererLifecycleResetCount increments, and fresh output flows immediately (a straggler ACK is clamped and cannot underflow). A renderer-side case (pty-dispatcher-pi-routing.test.ts) asserts ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls — proving the send fires (it is optional-chained) and the one-shot guard holds.",
"invariant": "After a renderer lifecycle reset (main-frame did-start-navigation / render-process-gone / destroyed), no surviving PTY remains delivery-gated by pre-reset unacked bytes: main's in-flight counters and pending backlog equal the true state of the new page (zero in-flight, zero pending). Delivery then resumes only once the reloaded page's pty:data dispatcher re-registers and signals pty:rendererDispatcherReady; during the boot window before that handshake main holds all sends (data accrues losslessly in the capped pending backlog) so bytes cannot be dropped into a listener-less page and re-pin the gate. The hold itself cannot become a permanent freeze: a one-shot ~10s watchdog armed on each reset force-opens the gate (incrementing rendererDispatcherReadyForcedCount) if the handshake is lost, and the real handshake or a re-registration cancels it. The reset fires only for a new-document main-frame navigation: did-start-navigation carries exact frame and same-document details, so overlapping subframe or in-page navigation never clears accounting or holds the gate on the still-alive page. If a renderer lifecycle edge is otherwise missed, a backstop still recovers: because the handshake is one-shot per page load, receiving pty:rendererDispatcherReady while the gate is already open proves a reset was missed (or the watchdog force-opened the gate), so the handler reconciles by clearing the stale accounting before re-opening. The renderer sends that handshake exactly once per page load, after its pty:data listener registers.",
"oracle": "Ingest more than 512 KB of PTY output with no renderer ACKs and assert the per-PTY gate closes (sends stop at the 512 KB high-water, remainder accrues as pending). Fire the registered main-frame did-start-navigation listener and assert rendererInFlightChars and pendingChars are zero and the new diagnostics record the reset (rendererLifecycleResetCount 1, lastLifecycleResetClearedChars 512 KB). Then, before any dispatcher-ready handshake, ingest another chunk and assert it is NOT sent and NOT counted in-flight (held for the boot window, accruing in pending). Finally fire the pty:rendererDispatcherReady handshake and assert the held chunk is delivered to the renderer. Counters-zero without proving both the boot-window hold and that delivery resumes is insufficient. Additional cases prove the boot-window hold also covers the interactive direct-send fast path (input-primed keystroke echo is held, not sent, until the handshake) and that the self-heal watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) when no handshake arrives, while a timely handshake cancels the watchdog and leaves no orphaned timer. A further case opens the new page with a main-frame navigation and dispatcher handshake, then fires an overlapping subframe navigation and asserts the gate stays ready, the reset count stays at exactly one, and fresh output delivers without the watchdog — proving an iframe cannot reclose the live page. A backstop case saturates the gate, then fires pty:rendererDispatcherReady while the gate is still open (ready true) with no preceding reset — modeling a missed lifecycle edge — and asserts the handler reconciles: in-flight and pending clear, rendererLifecycleResetCount increments, and fresh output flows immediately (a straggler ACK is clamped and cannot underflow). A renderer-side case (pty-dispatcher-pi-routing.test.ts) asserts ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls — proving the send fires (it is optional-chained) and the one-shot guard holds.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts"
],
@ -7795,12 +7849,12 @@
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"a PTY saturated past the 512 KB per-PTY high-water with no ACKs stops sending and accrues pending output (gate closed)",
"firing the registered did-start-loading listener zeroes rendererInFlightChars and pendingData and records rendererLifecycleResetCount and lastLifecycleResetClearedChars",
"firing the registered main-frame did-start-navigation listener zeroes rendererInFlightChars and pendingData and records rendererLifecycleResetCount and lastLifecycleResetClearedChars",
"after the reset, output ingested during the boot window is NOT sent and NOT counted in-flight until the pty:rendererDispatcherReady handshake fires (held in pending)",
"firing the pty:rendererDispatcherReady handshake releases the held backlog and delivery resumes (delivery gated on the handshake, not just counters cleared)",
"interactive input-primed keystroke echo is also held during the boot window (interactive fast path gated on the handshake) and delivered once it fires",
"when no handshake arrives, the ~10s watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) and the held backlog drains; a timely handshake cancels the watchdog and leaves no orphaned timer",
"a did-start-loading with isLoadingMainFrame() false (in-page subframe/iframe load) does NOT reset accounting (rendererLifecycleResetCount stays 0, pending and in-flight preserved, rendererPtyDispatcherReady stays true) and delivery still drains on ACK",
"an overlapping subframe did-start-navigation after the fresh dispatcher handshake does NOT reclose delivery (rendererLifecycleResetCount stays 1, rendererPtyDispatcherReady stays true, forced count stays 0) and fresh output delivers immediately",
"a pty:rendererDispatcherReady handshake arriving while the gate is still open (ready true, no preceding reset — a missed lifecycle edge) reconciles the stale accounting: in-flight and pending clear, rendererLifecycleResetCount increments, fresh output flows, and a straggler ACK is clamped",
"re-registering handlers (macOS re-activate / new window) cancels the prior registration's armed dispatcher-ready watchdog via the cross-registration bridge, leaving no orphaned ~10s timer to force-open a dead window's gate"
]
@ -7814,13 +7868,13 @@
],
"evidenceRuns": [
{
"date": "2026-07-09",
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts",
"result": "passed",
"durationSeconds": 1,
"summary": "243 tests passed (232 main-process + 11 renderer dispatcher) including the lifecycle-reset, boot-window (dispatcher-ready handshake), interactive-gate hold, watchdog self-heal, main-frame-filter (subframe did-start-loading is ignored), missed-reset reconcile backstop (handshake-while-open), cross-registration watchdog-cancel, and renderer-side one-shot handshake-send regressions. Removing the reset call reproduces the reload freeze (rendererInFlightChars stays 524288); removing the send-hold reproduces the boot-window leak; removing the interactive-path flag check sends keystroke echo into the not-yet-ready page; removing the watchdog arm leaves the gate held forever; removing the watchdog cancel leaves an orphaned timer after the handshake; removing the isLoadingMainFrame filter lets a subframe iframe load run a spurious reset; removing the handshake-while-open reconcile leaves the survivors pinned at 524288 after a missed lifecycle edge."
"durationSeconds": 5,
"summary": "439 tests passed including the lifecycle-reset, boot-window (dispatcher-ready handshake), interactive-gate hold, watchdog self-heal, exact-navigation filter (overlapping subframe navigation is ignored), missed-reset reconcile backstop (handshake-while-open), cross-registration watchdog-cancel, and renderer-side one-shot handshake-send regressions. Removing the reset call reproduces the reload freeze (rendererInFlightChars stays 524288); removing the send-hold reproduces the boot-window leak; removing the interactive-path flag check sends keystroke echo into the not-yet-ready page; removing the watchdog arm leaves the gate held forever; removing the watchdog cancel leaves an orphaned timer after the handshake; switching back to aggregate did-start-loading state lets an overlapping iframe load reclose the gate; removing the handshake-while-open reconcile leaves the survivors pinned at 524288 after a missed lifecycle edge."
}
],
"runtimeBudget": {
@ -7833,7 +7887,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Locally verified red/green on every load-bearing branch: (1) reset call removed -> rendererInFlightChars stays 524288 after did-start-loading; (2) boot-window send-hold removed -> post-reload output is sent into the not-yet-ready page ('NOT sent until handshake' fails); (3) interactive-path flag check removed -> input-primed keystroke echo is sent during the hold; (4) watchdog arm removed -> the gate is never force-opened and the held backlog never drains; (5) watchdog cancel removed -> an orphaned ~10s timer survives the handshake (getTimerCount 1); (6) isLoadingMainFrame filter removed -> a subframe did-start-loading runs a spurious reset (rendererLifecycleResetCount 1, pending cleared, ready dropped) — locally verified red; (7) handshake-while-open reconcile removed -> a pty:rendererDispatcherReady arriving after a missed lifecycle edge leaves the gate pinned (rendererInFlightChars stays 524288, pending 90112, rendererLifecycleResetCount 0) — locally verified red; (8) cross-registration bridge cancel removed (top-of-registerPtyHandlers clearRendererDispatcherReadyWatchdog) -> a prior registration's armed watchdog survives re-registration as an orphaned timer (getTimerCount 1 instead of 0) — locally verified red. With the full fix all eight are green. The performance budget below still holds: the watchdog is a single unref'd one-shot per reset, not per-chunk. Needs a saved CI or intentional-break artifact before blocking promotion."
"evidence": "Locally verified red/green on every load-bearing branch: (1) reset call removed -> rendererInFlightChars stays 524288 after main-frame did-start-navigation; (2) boot-window send-hold removed -> post-reload output is sent into the not-yet-ready page ('NOT sent until handshake' fails); (3) interactive-path flag check removed -> input-primed keystroke echo is sent during the hold; (4) watchdog arm removed -> the gate is never force-opened and the held backlog never drains; (5) watchdog cancel removed -> an orphaned ~10s timer survives the handshake (getTimerCount 1); (6) aggregate did-start-loading classification restored -> an overlapping subframe navigation recloses the gate after the handshake (ready false until watchdog) — deterministically red; (7) handshake-while-open reconcile removed -> a pty:rendererDispatcherReady arriving after a missed lifecycle edge leaves the gate pinned (rendererInFlightChars stays 524288, pending 90112, rendererLifecycleResetCount 0) — locally verified red; (8) cross-registration bridge cancel removed (top-of-registerPtyHandlers clearRendererDispatcherReadyWatchdog) -> a prior registration's armed watchdog survives re-registration as an orphaned timer (getTimerCount 1 instead of 0) — locally verified red. With the full fix all eight are green. The performance budget below still holds: the watchdog is a single unref'd one-shot per reset, not per-chunk. Needs a saved CI or intentional-break artifact before blocking promotion."
},
"performanceBudget": {
"required": true,

View File

@ -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(30)
expect(PROTOCOL_VERSION).toBe(31)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@ -13,5 +13,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30)
})
})

View File

@ -6,6 +6,7 @@ import {
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact
@ -13,7 +14,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(30)
expect(PROTOCOL_VERSION).toBe(31)
expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31)
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)
@ -21,7 +23,7 @@ describe('daemon protocol version', () => {
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: 29 }, (_, index) => index + 1)
Array.from({ length: 30 }, (_, index) => index + 1)
)
})

View File

@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v30 transfers large cold-restore seeds across bounded NDJSON messages.
export const PROTOCOL_VERSION = 30
// v31 exposes attach-only PTY adoption so a mount cannot create over a live stable pane.
export const PROTOCOL_VERSION = 31
export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
@ -22,7 +23,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, 29
28, 29, 30
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {

View File

@ -1411,6 +1411,69 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.snapshot).toBeUndefined()
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
it('forwards attach-only and never creates an absent stable session', async () => {
const subprocessBeforeAttach = lastSubprocess
await expect(
adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(lastSubprocess).toBe(subprocessBeforeAttach)
})
it('does not inspect cold history for attach-only ownership checks', async () => {
const historyDir = join(dir, 'attach-only-history')
const historyAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
historyPath: historyDir
})
const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader
const probe = vi.spyOn(reader, 'probeRestorableHistory')
const getAppliedSize = vi.spyOn(historyAdapter, 'getAppliedSize')
try {
await expect(
historyAdapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-attach-only-history-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-attach-only-history-session')
expect(probe).not.toHaveBeenCalled()
expect(getAppliedSize).not.toHaveBeenCalled()
} finally {
historyAdapter.dispose()
}
})
it('fails closed before dispatching attach-only to a v30 daemon', async () => {
const ensureConnected = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const request = vi.spyOn(DaemonClient.prototype, 'request')
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
await expect(
legacy.spawn({
cols: 80,
rows: 24,
sessionId: 'legacy-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('terminal_pane_owner_unknown')
expect(request).not.toHaveBeenCalledWith('createOrAttach', expect.anything())
} finally {
legacy.dispose()
request.mockRestore()
ensureConnected.mockRestore()
}
})
})
describe('attach', () => {

View File

@ -35,7 +35,10 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import {
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
} from './daemon-protocol-version'
import {
isAgentSessionClaimedSpawnResult,
isAgentSessionOwnerBinding,
@ -331,6 +334,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
if (opts.attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) {
throw new Error('terminal_pane_owner_unknown')
}
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
@ -449,7 +455,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
const historyProbe = this.historyReader?.probeRestorableHistory(sessionId)
const historyProbe = opts.attachOnly
? undefined
: this.historyReader?.probeRestorableHistory(sessionId)
if (historyProbe && historyProbe.status !== 'none') {
if ((await this.getAppliedSize(sessionId)) !== null) {
restoreSkippedForLiveSession = true
@ -496,6 +504,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
launchAgent: opts.launchAgent,
...(opts.attachOnly ? { attachOnly: true } : {}),
// Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider.
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,

View File

@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonClient } from './client'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle {
let onExit: ((code: number) => void) | undefined
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((callback) => {
onExit = callback
}),
dispose: vi.fn()
}
}
describe('DaemonServer attach-only preparation', () => {
const servers: DaemonServer[] = []
const clients: DaemonClient[] = []
const directories: string[] = []
afterEach(async () => {
for (const client of clients.splice(0)) {
client.disconnect()
}
await Promise.all(servers.splice(0).map((server) => server.shutdown()))
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
it('skips fresh-spawn preparation when attaching only', async () => {
const directory = mkdtempSync(join(tmpdir(), 'daemon-attach-only-test-'))
directories.push(directory)
const socketPath = getDaemonSocketPath(directory)
const tokenPath = join(directory, 'test.token')
const preparePtySpawn = vi.fn(async () => {})
const server = new DaemonServer({
socketPath,
tokenPath,
preparePtySpawn,
spawnSubprocess: () => createMockSubprocess()
})
servers.push(server)
await server.start()
const client = new DaemonClient({ socketPath, tokenPath })
clients.push(client)
await client.ensureConnected()
await client.request('createOrAttach', {
sessionId: 'stable-pane-session',
cols: 80,
rows: 24
})
await expect(
client.request('createOrAttach', {
sessionId: 'stable-pane-session',
cols: 120,
rows: 40,
attachOnly: true
})
).resolves.toMatchObject({ isNew: false })
expect(preparePtySpawn).toHaveBeenCalledOnce()
})
})

View File

@ -733,7 +733,9 @@ export class DaemonServer {
) {
throw new Error('agent_session_identity_required')
}
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
if (!p.attachOnly) {
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
}
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
throw new Error('Multiple terminal history seed sources')
}
@ -752,6 +754,7 @@ export class DaemonServer {
envToDelete: p.envToDelete,
command: p.command,
startupCommandDelivery: p.startupCommandDelivery,
...(p.attachOnly === true ? { attachOnly: true } : {}),
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
shellOverride: p.shellOverride,

View File

@ -4,7 +4,6 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos
export type InternalCreateOrAttachOptions = CreateOrAttachOptions & {
agentSessionGeneration?: string
attachOnly?: boolean
}
export async function createOrAttachClaimedAgentSession(args: {

View File

@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { SubprocessHandle } from './session'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
describe('TerminalHost attach-only sessions', () => {
let host: TerminalHost
let spawnSubprocess: Mock<SpawnSubprocess>
beforeEach(() => {
spawnSubprocess = vi.fn<SpawnSubprocess>(() => {
let onExit: ((code: number) => void) | undefined
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((callback) => {
onExit = callback
}),
dispose: vi.fn()
} as SubprocessHandle
})
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
await host.dispose()
})
it('attaches only to an existing stable session', async () => {
await host.createOrAttach({
sessionId: 'stable-pane-session',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const result = await host.createOrAttach({
sessionId: 'stable-pane-session',
cols: 120,
rows: 40,
attachOnly: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(result.isNew).toBe(false)
expect(spawnSubprocess).toHaveBeenCalledOnce()
})
it('does not create when an attach-only stable session is absent', async () => {
await expect(
host.createOrAttach({
sessionId: 'missing-stable-pane-session',
cols: 80,
rows: 24,
attachOnly: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(spawnSubprocess).not.toHaveBeenCalled()
})
})

View File

@ -19,6 +19,8 @@ export type CreateOrAttachOptions = {
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
/** Missing ownership is not permission to create during stable-pane adoption. */
attachOnly?: boolean
/** Explicit shell the renderer asked for, forwarded to the subprocess. */
shellOverride?: string
terminalWindowsWslDistro?: string | null

View File

@ -68,6 +68,8 @@ export type CreateOrAttachRequest = {
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
/** Rejects an absent session instead of interpreting mount uncertainty as create permission. */
attachOnly?: boolean
/** Explicit Windows shell override selected by the user (e.g. 'wsl.exe').
* The daemon forwards this to its subprocess spawner so each tab honors
* the shell picked in the "+" menu or the persisted default-shell setting,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -258,6 +258,55 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing stable session', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: 'stable-pane-session',
attachOnly: true
})
expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true })
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when an attach-only stable session is absent', async () => {
await expect(
provider.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing numeric provider session', async () => {
const first = await provider.spawn({ cols: 80, rows: 24 })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: first.id,
attachOnly: true
})
expect(result).toMatchObject({ id: first.id, isReattach: true })
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when a numeric attach-only provider session is absent', async () => {
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true })
).rejects.toThrow('Session not found: 404')
expect(spawnMock).not.toHaveBeenCalled()
})
it('keeps a native UNC session native on a conflicting WSL reattach', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const first = await provider.spawn({

View File

@ -369,9 +369,12 @@ function cancelAllPendingLocalPtySpawns(): void {
/**
* Normalizes renderer session ids that should be reused for local PTY reattach.
*/
function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null {
function normalizeLocalCallerSessionId(
sessionId: string | undefined,
allowNumeric = false
): string | null {
const requested = sessionId?.trim()
if (!requested || /^\d+$/.test(requested)) {
if (!requested || (!allowNumeric && /^\d+$/.test(requested))) {
return null
}
return requested
@ -525,7 +528,7 @@ export class LocalPtyProvider implements IPtyProvider {
* Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed.
*/
async spawn(args: PtySpawnOptions): Promise<PtySpawnResult> {
const reattachId = normalizeLocalCallerSessionId(args.sessionId)
const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true)
if (reattachId) {
const pendingShutdown = ptyShutdownOperations.get(reattachId)
if (pendingShutdown) {
@ -536,6 +539,9 @@ export class LocalPtyProvider implements IPtyProvider {
return existing
}
}
if (args.attachOnly) {
throw new Error(`Session not found: ${args.sessionId ?? ''}`)
}
const id = allocatePtyId(reattachId ?? undefined)
const incarnationId = randomUUID()

View File

@ -62,6 +62,8 @@ export type PtySpawnOptions = {
* Existing-session attach paths must stay false so recovery checks do not
* replace the daemon out from under a still-live PTY. */
isNewSession?: boolean
/** Attach the named session atomically or fail without creating a process. */
attachOnly?: boolean
/** Why: allows the renderer to request a specific shell for a single new
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
* changing the user's persistent default shell setting. Only consulted on

View File

@ -23064,6 +23064,109 @@ describe('OrcaRuntimeService', () => {
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
})
it('preserves host metadata when terminal.create adopts a stable pane owner', async () => {
const adoptStablePane = vi.fn().mockResolvedValue(null)
const spawn = vi.fn(async (opts: { adoptedStablePane?: { owner: { handle?: string } } }) =>
opts.adoptedStablePane
? {
id: 'pty-stable-owner',
isReattach: true,
stablePaneOwner: {
handle: opts.adoptedStablePane.owner.handle!,
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID
}
}
: { id: 'pty-stable-owner' }
)
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
adoptStablePane,
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const first = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Original owner',
launchAgent: 'claude'
})
adoptStablePane.mockResolvedValueOnce({
result: { id: 'pty-stable-owner', isReattach: true },
owner: {
handle: first.handle,
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'pty-stable-owner'
}
})
const adopted = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Replacement intent',
command: "claude 'replacement'",
launchAgent: 'claude'
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(adopted).toMatchObject({
handle: first.handle,
ptyId: 'pty-stable-owner',
title: 'Original owner',
isReattach: true
})
expect(listed.tabs).toEqual([
expect.objectContaining({
parentTabId: 'stable-owner-tab',
title: 'Original owner',
launchAgent: 'claude'
})
])
expect(spawn.mock.calls[1]?.[0]).toMatchObject({
command: "claude 'replacement'",
adoptedStablePane: expect.anything()
})
expect(spawn.mock.calls[1]?.[0]).not.toMatchObject({
command: expect.stringContaining('--teammate-mode')
})
})
it('releases a stable-pane claim when creation aborts before provider spawn', async () => {
const releaseClaim = vi.fn()
const spawn = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
claimStablePaneCreate: vi.fn(() => releaseClaim),
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const abort = new AbortController()
abort.abort()
await expect(
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'aborted-stable-pane',
leafId: HEADLESS_LEAF_ID,
signal: abort.signal
})
).rejects.toThrow('client_disconnected')
expect(spawn).not.toHaveBeenCalled()
expect(releaseClaim).toHaveBeenCalledOnce()
})
it('publishes the hook provider session on a headless mobile tab so native chat can address the transcript', async () => {
const paneKey = makePaneKey('claude-tab', HEADLESS_LEAF_ID)
const providerSession = {

View File

@ -527,6 +527,7 @@ import type {
IFilesystemProvider,
IPtyProvider,
PtyProcessInfo,
PtySpawnResult,
PtyTransientFact
} from '../providers/types'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
@ -1531,6 +1532,32 @@ type HeadlessSeedMetadata = {
}
type RuntimePtyController = {
claimStablePaneCreate?(args: {
worktreeId: string
connectionId: string | null
tabId: string
leafId: string
}): () => void
adoptStablePane?(opts: {
cols: number
rows: number
cwd?: string
connectionId: string | null
worktreeId: string
preAllocatedHandle: string
tabId: string
leafId: string
}): Promise<{
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
} | null>
spawn?(opts: {
cols: number
rows: number
@ -1558,10 +1585,22 @@ type RuntimePtyController = {
agentSessionCreateOperationId?: string
signal?: AbortSignal
onPtySpawnCommitted?: () => void
adoptedStablePane?: {
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
}
}): Promise<{
id: string
incarnationId?: PtyIncarnationId
wslDistro?: string
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}>
write(ptyId: string, data: string): boolean
@ -15756,6 +15795,7 @@ export class OrcaRuntimeService {
tabId: parsed?.tabId ?? record?.tabId ?? '',
leafId: parsed?.leafId ?? record?.leafId ?? '',
ptyId: record?.ptyId ?? null,
connected: pty?.connected === true,
...(worktreeId ? { worktreeId } : {}),
...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null)
}
@ -24440,236 +24480,298 @@ export class OrcaRuntimeService {
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID()
let paneKey = makePaneKey(tabId, leafId)
const launchToken = launchOpts.launchConfig
? (launchOpts.launchToken ?? randomUUID())
: undefined
const baseEnv = {
...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsSourceCommand =
launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
launchOpts.launchConfig,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
launchOpts.command &&
claudeAgentTeamsSourceCommand !== launchOpts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
launchOpts.launchConfig && agentTeamsPlan
? {
...launchOpts.launchConfig,
agentCommand: launchOpts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
...launchOpts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
: launchOpts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
{
...baseEnv,
...(sequencedStartupCommand
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd,
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: mergeTerminalEnvDeletionKeys(
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
worktreeId: workspace.id,
preAllocatedHandle,
connectionId: workspace.connectionId,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
agentSessionEnsure: {
claim: launchOpts.agentSessionClaim,
surface: {
worktreeId: workspace.id,
tabId,
leafId,
terminalHandle: preAllocatedHandle
}
}
}
: {}),
...(launchOpts.agentSessionCreateOperationId
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
: {}),
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null
? { persistHostSessionBinding: true }
: {})
leafId
})
reportPtySpawnCommitted()
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = makePaneKey(tabId, leafId)
let stablePaneCreateReleased = false
const releaseStablePaneCreate = (): void => {
if (stablePaneCreateReleased) {
return
}
stablePaneCreateReleased = true
claimedStablePaneCreate?.()
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (launchOpts.persistHostSessionBinding) {
pty.runtimeSessionOwned = true
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
cols: 120,
rows: 40,
cwd,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId
})
const launchToken = launchOpts.launchConfig
? (launchOpts.launchToken ?? randomUUID())
: undefined
const baseEnv = {
...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
const claudeAgentTeamsSourceCommand =
launchOpts.claudeAgentTeamsSourceCommand?.trim() ||
launchOpts.command?.trim() ||
undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
launchOpts.launchConfig,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
let agentTeamsPlan: Awaited<ReturnType<typeof buildClaudeAgentTeamsLaunchPlan>> | undefined
try {
agentTeamsPlan = adoptedBeforeLaunch
? undefined
: await buildClaudeAgentTeamsLaunchPlan({
command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
} catch (error) {
releaseStablePaneCreate?.()
throw error
}
pty.tabId = tabId
pty.paneKey = paneKey
pty.launchConfig = effectiveLaunchConfig
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchAgent = launchOpts.launchAgent ?? null
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
launchOpts.command &&
claudeAgentTeamsSourceCommand !== launchOpts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
launchOpts.launchConfig && agentTeamsPlan
? {
...launchOpts.launchConfig,
agentCommand: launchOpts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
...launchOpts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
: launchOpts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
{
...baseEnv,
...(sequencedStartupCommand
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const persistHostSessionBinding =
launchOpts.persistHostSessionBinding ||
launchOpts.surfaceOwner === false ||
this.getAvailableAuthoritativeWindow() === null
let result: Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
try {
result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd,
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: mergeTerminalEnvDeletionKeys(
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
agentSessionEnsure: {
claim: launchOpts.agentSessionClaim,
surface: {
worktreeId: workspace.id,
tabId,
leafId,
terminalHandle: preAllocatedHandle
}
}
}
: {}),
...(launchOpts.agentSessionCreateOperationId
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
: {}),
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
...(launchOpts.onPtySpawnCommitted
? { onPtySpawnCommitted: reportPtySpawnCommitted }
: {}),
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
})
} finally {
releaseStablePaneCreate?.()
}
if (!result.stablePaneOwner) {
reportPtySpawnCommitted()
}
const adoptedStablePane = Boolean(result.stablePaneOwner)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
paneKey = makePaneKey(tabId, leafId)
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
// failing here must not strand a live process without returning a handle.
// Pass the pre-minted tabId so the renderer adopts under the same id
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = createTerminalRevealWarning(handle, err)
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (persistHostSessionBinding) {
pty.runtimeSessionOwned = true
}
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.launchConfig = effectiveLaunchConfig
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
} else if (presentation !== 'background') {
warning = createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(warning ? { warning } : {})
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
// failing here must not strand a live process without returning a handle.
// Pass the pre-minted tabId so the renderer adopts under the same id
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = createTerminalRevealWarning(handle, err)
}
} else if (presentation !== 'background') {
warning = createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(warning ? { warning } : {})
}
} finally {
releaseStablePaneCreate()
}
}

View File

@ -297,6 +297,7 @@ function resolveMockPaneWindowsShiftEnterEncoding(
}
type ConnectCallbacks = {
onReattachDetermined?: () => void
onConnect?: () => void
onData?: (
data: string,
@ -5479,6 +5480,62 @@ describe('connectPanePty', () => {
expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach')
})
it('replays a stable-pane adoption without submitting the SSH resume command', async () => {
const { connectPanePty } = await import('./pty-connection')
const stablePtyId = toAppSshPtyId('conn-1', 'stable-pane-session')
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }) => {
callbacks.onReattachDetermined?.()
transport.getPtyId.mockReturnValue(stablePtyId)
callbacks.onData?.('NEWER-LIVE-SSH-OUTPUT')
return {
id: stablePtyId,
isReattach: true,
replay: 'ORIGINAL-LIVE-SSH-OUTPUT'
}
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]])
}
const pane = createPane(1)
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
startup: { command: 'codex resume provider-session' }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(4)
if (!onDataHandler || parseCallbacks.length === 0) {
throw new Error('expected replay and terminal input handlers')
}
;(onDataHandler as (data: string) => void)('DURING_ADOPTION_REPLAY\r')
expect(transport.sendInput).not.toHaveBeenCalledWith('DURING_ADOPTION_REPLAY\r')
for (let step = 0; step < 30; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
;(onDataHandler as (data: string) => void)('AFTER_ADOPTION_REPLAY\r')
expect(pane.container.dataset.ptyId).toBe(stablePtyId)
expect(writes.join('')).toContain('ORIGINAL-LIVE-SSH-OUTPUT')
expect(writes.join('').indexOf('ORIGINAL-LIVE-SSH-OUTPUT')).toBeLessThan(
writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT')
)
expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r')
expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId)
})
it('drops xterm onData while pane is replaying restored bytes', async () => {
// Regression: during replay, xterm auto-replies to embedded queries (DA1/DECRQM/OSC/CPR) via onData must not reach transport.sendInput or they land as stray chars on the prompt. See replay-guard.ts.
const { connectPanePty } = await import('./pty-connection')
@ -10961,16 +11018,22 @@ describe('connectPanePty', () => {
expect(transport.sendInput).not.toHaveBeenCalled()
})
it('renders the reattach snapshot before live bytes delivered during the spawn reply', async () => {
it('drains live bytes after transport confirms an explicit reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const { deliverTerminalDataWithDeferredCredit } =
await import('@/lib/pane-manager/terminal-delivery-credit')
const transport = createMockTransport('tab-pty')
const acknowledgeLiveFrame = vi.fn()
transport.connect.mockImplementation(
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (!sessionId) {
return null
}
// Why: the real dispatcher drains post-snapshot bytes as soon as spawn IPC resolves, before connect() returns.
callbacks?.onData?.('post-snapshot-live')
callbacks?.onReattachDetermined?.()
deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => {
callbacks?.onData?.('post-snapshot-live')
})
return { id: sessionId, snapshot: 'authoritative-snapshot' }
}
)
@ -10988,13 +11051,14 @@ describe('connectPanePty', () => {
const snapshotIndex = writes.indexOf('authoritative-snapshot')
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
expect(writes).not.toContain('post-snapshot-live')
while (parseCallbacks.length > 0) {
for (let step = 0; step < 40; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
await flushAsyncTicks(8)
const liveIndex = writes.indexOf('post-snapshot-live')
expect(liveIndex).toBeGreaterThan(snapshotIndex)
expect(acknowledgeLiveFrame).toHaveBeenCalledOnce()
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty')
})
it('re-enforces follow intent after deferred reattach live output parses', async () => {

View File

@ -5167,6 +5167,7 @@ export function connectPanePty(
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
.then(async (spawnedPtyId) => {
if (outputCallbacks.generation !== transportStreamGeneration) {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
@ -5180,8 +5181,30 @@ export function connectPanePty(
? spawnedPtyId
: transport.getPtyId()
if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
return null
}
const connectResult =
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
? spawnedPtyId
: null
if (connectResult?.isReattach) {
pendingStartupCommand = null
const accepted = await handleReattachResult(
connectResult,
null,
coldRestoreOverride,
outputCallbacks.generation
)
finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
const gen = await preSignalPromise
if (accepted && resolvedPtyId && typeof gen === 'number') {
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
} else if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
}
return accepted ? resolvedPtyId : null
}
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
@ -5251,9 +5274,11 @@ export function connectPanePty(
if (resolvedPtyId && connectionId) {
schedulePendingStartupCommandDelivery()
}
finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation)
return resolvedPtyId
})
.catch(async () => {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) {
clearRegisteredStartupLaunchConfig()
}
@ -5683,6 +5708,11 @@ export function connectPanePty(
return {
generation,
callbacks: {
onReattachDetermined: (): void => {
if (isCurrent()) {
beginReattachLiveDataDeferralIfUnowned(generation)
}
},
onConnect: (): void => {
if (isCurrent()) {
reportRemoteRendererSerializerReady()
@ -7591,6 +7621,14 @@ export function connectPanePty(
}
}
const beginReattachLiveDataDeferralIfUnowned = (
ownerGeneration = transportStreamGeneration
): void => {
if (!deferredReattachLiveDataOwners.has(ownerGeneration)) {
beginReattachLiveDataDeferral(ownerGeneration)
}
}
const finishReattachLiveDataDeferral = (
deliver: boolean,
acceptedGeneration = transportStreamGeneration

View File

@ -72,6 +72,8 @@ export type PtyConnectResult = {
}
type PtyCallbacks = {
/** Called before an adopted PTY can publish buffered/live bytes. */
onReattachDetermined?: () => void
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string, meta?: PtyDataMeta) => void

View File

@ -133,6 +133,41 @@ describe('createIpcPtyTransport', () => {
expect(transport.isConnected()).toBe(false)
})
it('announces a daemon adoption before publishing its buffered PTY data', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
spawn.mockResolvedValueOnce({ id: 'adopted-pty', isReattach: true })
const order: string[] = []
const transport = createIpcPtyTransport({})
const connecting = transport.connect({
url: '',
callbacks: {
onReattachDetermined: () => order.push('adopt'),
onData: () => order.push('data')
}
})
onData?.({ id: 'adopted-pty', data: 'buffered' })
await connecting
expect(order).toEqual(['adopt', 'data'])
})
it('does not reannounce an explicit reattach already owned by its caller', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
spawn.mockResolvedValueOnce({ id: 'restored-pty', isReattach: true })
const onReattachDetermined = vi.fn()
const transport = createIpcPtyTransport({})
await transport.connect({
url: '',
sessionId: 'restored-pty',
callbacks: { onReattachDetermined }
})
expect(onReattachDetermined).not.toHaveBeenCalled()
})
it('forwards requested environment deletions to the PTY spawn', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
@ -2516,6 +2551,83 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
})
it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => {
runtimeCall.mockResolvedValue({
id: 'rpc-create',
ok: true,
result: {
terminal: {
handle: 'term-original',
worktreeId: 'repo1::/remote/wt',
title: 'Original',
surface: 'background',
isReattach: true
}
},
_meta: { runtimeId: 'runtime-remote' }
})
const onPtySpawn = vi.fn()
const onReattachDetermined = vi.fn()
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
onPtySpawn
})
const result = await transport.connect({
url: '',
callbacks: { onReattachDetermined }
})
expect(result).toEqual({
id: 'remote:env-1@@term-original',
replay: '',
isReattach: true
})
expect(onReattachDetermined).toHaveBeenCalledOnce()
expect(onPtySpawn).not.toHaveBeenCalled()
})
it('does not close an adopted stable-pane owner when create resolves after destroy', async () => {
let resolveCreate!: (value: unknown) => void
runtimeCall.mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve
})
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
})
const connecting = transport.connect({ url: '', callbacks: {} })
transport.destroy?.()
resolveCreate({
id: 'rpc-create',
ok: true,
result: {
terminal: {
handle: 'term-original',
worktreeId: 'repo1::/remote/wt',
title: 'Original',
surface: 'background',
isReattach: true
}
},
_meta: { runtimeId: 'runtime-remote' }
})
await connecting
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.close' })
)
})
it('suspends passive remote output until host sleep is cancelled', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { applyHostWorktreeTerminalSleepState } = await import('./pty-shutdown-exit-deferral')

View File

@ -822,6 +822,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return spawnResult
}
if (spawnResult.isReattach && !admittedSessionId) {
storedCallbacks.onReattachDetermined?.()
}
ptyId = spawnResult.id
connected = true

View File

@ -430,7 +430,7 @@ export function createRemoteRuntimePtyTransport(
result: RemoteAgentSessionLaunchResult,
environmentId: string
): boolean {
if (result.disposition !== undefined) {
if (result.disposition !== undefined || result.terminal.isReattach === true) {
// Why: every structured launch is host-owned; provisional teardown must
// never close its canonical terminal while snapshot reconciliation catches up.
return true
@ -1968,6 +1968,9 @@ export function createRemoteRuntimePtyTransport(
}
handle = createdTerminal.handle
if (createdTerminal.isReattach === true) {
storedCallbacks.onReattachDetermined?.()
}
remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId)
registerShutdownHandlers(remotePtyId)
connected = true
@ -1975,7 +1978,9 @@ export function createRemoteRuntimePtyTransport(
cols: options.cols ?? 80,
rows: options.rows ?? 24
}
onPtySpawn?.(remotePtyId)
if (createdTerminal.isReattach !== true) {
onPtySpawn?.(remotePtyId)
}
emitRecoveryState()
try {
@ -1991,7 +1996,8 @@ export function createRemoteRuntimePtyTransport(
return {
id: remotePtyId,
replay: ''
replay: '',
...(createdTerminal.isReattach === true ? { isReattach: true } : {})
} satisfies PtyConnectResult
} catch (error) {
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {

View File

@ -19,14 +19,27 @@ const retryByPane = new WeakMap<ManagedPane, RetryState>()
function scheduleRetryTick(run: () => void): RetrySchedule {
if (typeof requestAnimationFrame === 'function') {
let cancelled = false
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const finish = (): void => {
if (cancelled || settled) {
return
}
settled = true
run()
}
const rafId = requestAnimationFrame(() => {
if (!cancelled) {
if (!cancelled && !settled) {
// Why: FitAddon must observe committed CSS, and synchronous rAF test
// shims must not recursively consume the whole retry budget inline.
timer = setTimeout(run, LAYOUT_SETTLE_MS)
if (timer !== null) {
clearTimeout(timer)
}
timer = setTimeout(finish, LAYOUT_SETTLE_MS)
}
})
// Why: Chromium can indefinitely throttle rAF for a hidden Electron window; the fit budget must still release deferred PTY output.
timer = setTimeout(finish, LAYOUT_SETTLE_MS * 2)
return {
cancel: () => {
cancelled = true

View File

@ -185,6 +185,19 @@ describe('safeFitAndThen unmeasurable-pane retry', () => {
safeFit(pane)
expect(continuation).not.toHaveBeenCalled()
})
it('resolves failure when hidden-window animation frames are withheld', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
retryIfUnmeasurable: true
})
await vi.advanceTimersByTimeAsync(40 * 32)
expect(continuation).not.toHaveBeenCalled()
await expect(handle.completion).resolves.toBe(false)
})
})
describe('paneFitClientSizeChanged (reveal fit gate)', () => {

View File

@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 30,
"daemonProtocolVersion": 31,
"attachableDaemonProtocolVersions": [
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, 29, 30
27, 28, 29, 30, 31
]
}

View File

@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 30,
daemonProtocolVersion: 31,
attachableDaemonProtocolVersions: [
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, 29, 30
27, 28, 29, 30, 31
]
} as const

View File

@ -659,6 +659,8 @@ export type RuntimeTerminalCreate = {
warning?: string
/** Present only for the structured host-authority resume path. */
agentSessionDisposition?: 'created' | 'adopted'
/** The host attached this request to the existing stable pane owner. */
isReattach?: true
}
export type RuntimeTerminalSplit = {
@ -672,6 +674,7 @@ export type RuntimeTerminalResolvePane = {
tabId: string
leafId: string
ptyId: string | null
connected?: boolean
worktreeId?: string
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform

View File

@ -0,0 +1,825 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test as base, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal'
import {
clearTerminalPtyWriteLog,
installTerminalPtyWriteSpy,
readTerminalPtyWriteEntries
} from './helpers/terminal-pty-write-spy'
import { RuntimeClient } from '../../src/cli/runtime-client'
import type {
RuntimeStatus,
RuntimeTerminalCreate,
RuntimeTerminalListResult,
RuntimeTerminalRead,
RuntimeTerminalSummary,
RuntimeWorktreeCreateResult
} from '../../src/shared/runtime-types'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
import { makePaneKey } from '../../src/shared/stable-pane-id'
type SpawnEvent = { args: string[]; pid: number }
type TerminalIdentity = Pick<
RuntimeTerminalSummary,
'handle' | 'incarnationId' | 'leafId' | 'ptyId' | 'tabId'
>
const PROVIDER_SESSION_ID = '019fc155-00e1-7102-99a9-e7c72e532a8e'
const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-live-mount-cli-'))
const spawnLedgerPath = path.join(fakeCliDir, 'codex-spawn.jsonl')
const setupLedgerPath = path.join(fakeCliDir, 'setup-spawn.jsonl')
const canaryLedgerPath = path.join(fakeCliDir, 'canary-spawn.jsonl')
const signalLedgerPath = path.join(fakeCliDir, 'terminal-signals.jsonl')
const fakeCodexSource = `
const { appendFileSync } = require('node:fs')
const args = process.argv.slice(2)
if (args.includes('app-server')) {
process.stderr.write("error: unrecognized subcommand 'app-server'\\n")
process.exit(2)
}
appendFileSync(process.env.ORCA_E2E_CODEX_SPAWN_LEDGER, JSON.stringify({ args, pid: process.pid }) + '\\n')
process.stdout.write('LIVE_AGENT_READY:' + process.pid + '\\n')
let inputBuffer = ''
process.stdin.on('data', (chunk) => {
inputBuffer += chunk.toString()
const lines = inputBuffer.split(/[\\r\\n]+/)
inputBuffer = lines.pop() || ''
for (const line of lines) if (line) process.stdout.write('AGENT_INPUT:' + process.pid + ':' + line + '\\n')
})
for (const signal of ['SIGINT', 'SIGHUP', 'SIGTERM']) process.on(signal, () => appendFileSync(process.env.ORCA_E2E_SIGNAL_LEDGER, JSON.stringify({ kind: 'agent', pid: process.pid, signal }) + '\\n'))
process.stdin.resume()
setInterval(() => {}, 60_000)
`
if (process.platform === 'win32') {
writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource)
writeFileSync(
path.join(fakeCliDir, 'codex.cmd'),
'@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n'
)
} else {
const executable = path.join(fakeCliDir, 'codex')
writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`)
chmodSync(executable, 0o755)
}
const test = base.extend({
launchEnv: [
{
PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`,
ORCA_E2E_CODEX_SPAWN_LEDGER: spawnLedgerPath,
ORCA_E2E_SETUP_LEDGER: setupLedgerPath,
ORCA_E2E_CANARY_LEDGER: canaryLedgerPath,
ORCA_E2E_SIGNAL_LEDGER: signalLedgerPath
},
{ option: true }
]
})
function readSpawnLedger(): SpawnEvent[] {
if (!existsSync(spawnLedgerPath)) {
return []
}
return readFileSync(spawnLedgerPath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as SpawnEvent)
}
function readJsonLines<T>(filePath: string): T[] {
if (!existsSync(filePath)) {
return []
}
return readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as T)
}
function createSourceRepo(): string {
const repoPath = mkdtempSync(path.join(os.tmpdir(), 'orca-live-mount-repo-'))
writeFileSync(
path.join(repoPath, 'setup-live.js'),
`const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.ORCA_E2E_SETUP_LEDGER, JSON.stringify({ pid: process.pid }) + '\\n')\nconsole.log('SETUP_READY:' + process.pid)\nlet inputBuffer = ''\nprocess.stdin.on('data', chunk => {\n inputBuffer += chunk.toString()\n const lines = inputBuffer.split(/[\\r\\n]+/)\n inputBuffer = lines.pop() || ''\n for (const line of lines) if (line) console.log('SETUP_INPUT:' + process.pid + ':' + line)\n})\nfor (const signal of ['SIGINT', 'SIGHUP', 'SIGTERM']) process.on(signal, () => appendFileSync(process.env.ORCA_E2E_SIGNAL_LEDGER, JSON.stringify({ kind: 'setup', pid: process.pid, signal }) + '\\n'))\nprocess.stdin.resume()\nsetInterval(() => {}, 60000)\n`
)
writeFileSync(
path.join(repoPath, 'canary-live.js'),
`const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.ORCA_E2E_CANARY_LEDGER, JSON.stringify({ pid: process.pid }) + '\\n')\nconsole.log('CANARY_READY:' + process.pid)\nlet inputBuffer = ''\nprocess.stdin.on('data', chunk => {\n inputBuffer += chunk.toString()\n const lines = inputBuffer.split(/[\\r\\n]+/)\n inputBuffer = lines.pop() || ''\n for (const line of lines) if (line) console.log('CANARY_INPUT:' + process.pid + ':' + line)\n})\nfor (const signal of ['SIGINT', 'SIGHUP', 'SIGTERM']) process.on(signal, () => appendFileSync(process.env.ORCA_E2E_SIGNAL_LEDGER, JSON.stringify({ kind: 'canary', pid: process.pid, signal }) + '\\n'))\nprocess.stdin.resume()\nsetInterval(() => {}, 60000)\n`
)
writeFileSync(path.join(repoPath, 'orca.yaml'), 'scripts:\n setup: node setup-live.js\n')
execFileSync('git', ['init'], { cwd: repoPath })
execFileSync('git', ['checkout', '-b', 'main'], { cwd: repoPath })
execFileSync('git', ['add', '.'], { cwd: repoPath })
execFileSync(
'git',
['-c', 'user.name=Orca E2E', '-c', 'user.email=orca-e2e@example.com', 'commit', '-m', 'seed'],
{ cwd: repoPath }
)
return repoPath
}
async function readWorktreeTerminals(
client: RuntimeClient,
worktreeId: string
): Promise<RuntimeTerminalSummary[]> {
const listed = await client.call<RuntimeTerminalListResult>('terminal.list', {
worktree: `id:${worktreeId}`,
limit: 20,
requireFreshPtyLiveness: true
})
return listed.result.terminals
.filter((terminal) => terminal.worktreeId === worktreeId)
.sort((a, b) => a.handle.localeCompare(b.handle))
}
async function terminalOutput(client: RuntimeClient, handle: string): Promise<string> {
const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
terminal: handle,
limit: 300
})
return read.result.terminal.tail.join('\n')
}
function terminalIdentity(terminal: RuntimeTerminalSummary): TerminalIdentity {
const { handle, incarnationId, leafId, ptyId, tabId } = terminal
return { handle, incarnationId, leafId, ptyId, tabId }
}
function liveTerminalIdentity(terminal: RuntimeTerminalSummary) {
return {
...terminalIdentity(terminal),
connected: terminal.connected,
writable: terminal.writable
}
}
function readDaemonPid(userDataDir: string): number {
const raw = readFileSync(
path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`),
'utf8'
)
const parsed = JSON.parse(raw) as { pid?: unknown }
if (typeof parsed.pid !== 'number' || parsed.pid <= 0) {
throw new Error(`Daemon pid file did not contain a positive pid: ${raw}`)
}
return parsed.pid
}
async function seedAgentRecoveryMetadata(
page: Page,
worktreeId: string,
agent: TerminalIdentity
): Promise<void> {
const paneKey = makePaneKey(agent.tabId, agent.leafId)
const launchToken = `live-mount-${randomUUID()}`
await page.evaluate(
({ agent, launchToken, paneKey, providerSessionId, worktreeId }) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Renderer store unavailable')
}
const providerSession = { key: 'session_id' as const, id: providerSessionId }
state.registerAgentLaunchConfig(
paneKey,
{
agentCommand: 'codex',
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
{
agentType: 'codex',
launchToken,
tabId: agent.tabId,
leafId: agent.leafId,
terminalHandle: agent.handle,
providerSession
}
)
state.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'keep running', agentType: 'codex' },
'Codex',
undefined,
{ tabId: agent.tabId, worktreeId, terminalHandle: agent.handle },
{ providerSession, launchToken }
)
},
{ agent, launchToken, paneKey, providerSessionId: PROVIDER_SESSION_ID, worktreeId }
)
await expect
.poll(() =>
page.evaluate(
({ paneKey, providerSessionId, worktreeId }) => {
const state = window.__store?.getState()
const live = state?.agentStatusByPaneKey[paneKey]
const sleeping = state?.sleepingAgentSessionsByPaneKey[paneKey]
return {
liveProviderSessionId: live?.providerSession?.id ?? null,
sleeping: sleeping
? {
paneKey: sleeping.paneKey,
tabId: sleeping.tabId,
worktreeId: sleeping.worktreeId,
origin: sleeping.origin,
providerSessionId: sleeping.providerSession.id,
agentCommand: sleeping.launchConfig?.agentCommand ?? null
}
: null,
expected: { paneKey, providerSessionId, worktreeId }
}
},
{ paneKey, providerSessionId: PROVIDER_SESSION_ID, worktreeId }
)
)
.toEqual({
liveProviderSessionId: PROVIDER_SESSION_ID,
sleeping: {
paneKey,
tabId: agent.tabId,
worktreeId,
origin: 'live',
providerSessionId: PROVIDER_SESSION_ID,
agentCommand: 'codex'
},
expected: { paneKey, providerSessionId: PROVIDER_SESSION_ID, worktreeId }
})
}
async function readRendererBindings(page: Page, identities: TerminalIdentity[]) {
return page.evaluate((targets) => {
const state = window.__store?.getState()
return targets.map(({ leafId, tabId }) => ({
tabId,
tabPtyId:
Object.values(state?.tabsByWorktree ?? {})
.flat()
.find((tab) => tab.id === tabId)?.ptyId ?? null,
ptyIds: state?.ptyIdsByTabId[tabId] ?? [],
leafBindings: Object.entries(state?.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}).sort(
([left], [right]) => left.localeCompare(right)
),
leafId
}))
}, identities)
}
async function readPersistedBindings(
page: Page,
worktreeId: string,
identities: TerminalIdentity[]
) {
return page.evaluate(
async ({ identities, worktreeId }) => {
const session = await window.api.session.get()
return identities.map(({ leafId, tabId }) => ({
tabId,
tabPtyId:
session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.ptyId ?? null,
leafBindings: Object.entries(
session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
).sort(([left], [right]) => left.localeCompare(right)),
leafId
}))
},
{ identities, worktreeId }
)
}
function expectedBindings(identities: TerminalIdentity[], includeLiveIds: boolean) {
return identities.map(({ leafId, ptyId, tabId }) => ({
tabId,
tabPtyId: ptyId,
...(includeLiveIds ? { ptyIds: [ptyId] } : {}),
leafBindings: [[leafId, ptyId]],
leafId
}))
}
async function assertTargetBindings(
page: Page,
worktreeId: string,
identities: TerminalIdentity[]
): Promise<void> {
await expect
.poll(() => readRendererBindings(page, identities), { timeout: 15_000 })
.toEqual(expectedBindings(identities, true))
await expect
.poll(() => readPersistedBindings(page, worktreeId, identities), { timeout: 15_000 })
.toEqual(expectedBindings(identities, false))
}
async function assertLiveInventory(
client: RuntimeClient,
worktreeId: string,
originals: RuntimeTerminalSummary[]
): Promise<void> {
await expect
.poll(async () => (await readWorktreeTerminals(client, worktreeId)).map(liveTerminalIdentity), {
timeout: 15_000
})
.toEqual(originals.map(liveTerminalIdentity))
}
async function assertLaunchLedgersUnchanged(): Promise<void> {
await expect
.poll(
() => ({
agent: readSpawnLedger().length,
setup: readJsonLines<{ pid: number }>(setupLedgerPath).length,
canary: readJsonLines<{ pid: number }>(canaryLedgerPath).length
}),
{ timeout: 10_000 }
)
.toEqual({ agent: 1, setup: 1, canary: 1 })
const agentLaunches = readSpawnLedger()
expect(agentLaunches.filter(({ args }) => args.includes('resume'))).toHaveLength(0)
expect(agentLaunches.filter(({ args }) => args.includes(PROVIDER_SESSION_ID))).toHaveLength(0)
}
async function assertNoInterruption(
client: RuntimeClient,
terminals: RuntimeTerminalSummary[]
): Promise<void> {
const outputs = await Promise.all(
terminals.map((terminal) => terminalOutput(client, terminal.handle))
)
expect(outputs.join('\n')).not.toContain('Conversation interrupted')
}
async function faultProjectionAndActivate(
page: Page,
worktreeId: string,
terminals: RuntimeTerminalSummary[],
activeTabId: string
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
({ tabIds, worktreeId }) => {
const state = window.__store?.getState()
const tabs = state?.tabsByWorktree[worktreeId] ?? []
return tabIds.every(
(tabId) =>
tabs.some((tab) => tab.id === tabId) &&
Boolean(state?.terminalLayoutsByTabId[tabId]?.root) &&
!window.__paneManagers?.has(tabId)
)
},
{ tabIds: terminals.map((terminal) => terminal.tabId), worktreeId }
)
)
.toBe(true)
await page.evaluate(
({ activeTabId, identities, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('Renderer store unavailable')
}
store.setState((state) => {
const tabsByWorktree = { ...state.tabsByWorktree }
tabsByWorktree[worktreeId] = (tabsByWorktree[worktreeId] ?? []).map((tab) =>
identities.some((identity) => identity.tabId === tab.id) ? { ...tab, ptyId: null } : tab
)
const ptyIdsByTabId = { ...state.ptyIdsByTabId }
const terminalLayoutsByTabId = { ...state.terminalLayoutsByTabId }
for (const identity of identities) {
ptyIdsByTabId[identity.tabId] = []
const layout = terminalLayoutsByTabId[identity.tabId]
if (layout) {
const ptyIdsByLeafId = { ...layout.ptyIdsByLeafId }
delete ptyIdsByLeafId[identity.leafId]
terminalLayoutsByTabId[identity.tabId] = {
...layout,
ptyIdsByLeafId
}
}
}
return { tabsByWorktree, ptyIdsByTabId, terminalLayoutsByTabId }
})
const next = store.getState()
next.setActiveRepo(
next.repos.find((repo) => repo.id === worktreeId.split('::')[0])?.id ?? null
)
next.setActiveTabForWorktree(worktreeId, activeTabId)
next.setActiveView('terminal')
next.setActiveWorktree(worktreeId)
},
{
activeTabId,
identities: terminals.map(({ tabId, leafId }) => ({ tabId, leafId })),
worktreeId
}
)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
}
async function activateTerminal(page: Page, worktreeId: string, tabId: string): Promise<void> {
await page.evaluate(
({ tabId, worktreeId }) => {
const state = window.__store?.getState()
state?.setActiveRepo(
state.repos.find((repo) => repo.id === worktreeId.split('::')[0])?.id ?? null
)
state?.setActiveTabForWorktree(worktreeId, tabId)
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
},
{ tabId, worktreeId }
)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
await page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`).click({ force: true })
}
async function enableTerminalAccessibility(page: Page, tabId: string): Promise<void> {
await page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
if (!pane) {
throw new Error(`Terminal pane unavailable: ${id}`)
}
pane.terminal.options.screenReaderMode = true
pane.terminal.refresh(0, pane.terminal.rows - 1)
}, tabId)
await expect(
page.locator(`[data-terminal-tab-id=${JSON.stringify(tabId)}] .xterm-accessibility-tree`)
).toBeAttached({ timeout: 10_000 })
}
function terminalAccessibility(page: Page, tabId: string) {
return page.locator(`[data-terminal-tab-id=${JSON.stringify(tabId)}] .xterm-accessibility-tree`)
}
async function terminalViewportText(page: Page, tabId: string): Promise<string> {
return page.evaluate((id) => {
const pane = window.__paneManagers?.get(id)?.getActivePane?.()
if (!pane) {
throw new Error(`Terminal pane unavailable: ${id}`)
}
const buffer = pane.terminal.buffer.active
return Array.from(
{ length: pane.terminal.rows },
(_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''
).join('\n')
}, tabId)
}
async function typeIntoTerminal(page: Page, tabId: string, marker: string): Promise<void> {
const terminal = page.locator(`[data-terminal-tab-id=${JSON.stringify(tabId)}] .xterm:visible`)
await terminal.click({ force: true })
await page.keyboard.type(marker, { delay: 20 })
await page.keyboard.press('Enter')
}
async function assertExactPtyReceivedMarker(
electronApp: Parameters<typeof readTerminalPtyWriteEntries>[0],
ptyId: string,
marker: string
): Promise<void> {
const command = `${marker}\r`
await expect
.poll(async () => {
const entries = await readTerminalPtyWriteEntries(electronApp)
return entries
.filter((entry) => entry.id === ptyId)
.map((entry) => entry.data)
.join('')
})
.toContain(command)
const unrelatedWrites = (await readTerminalPtyWriteEntries(electronApp))
.filter((entry) => entry.id !== ptyId)
.map((entry) => entry.data)
.join('')
expect(unrelatedWrites).not.toContain(command)
}
test.afterEach(() => {
rmSync(spawnLedgerPath, { force: true })
rmSync(setupLedgerPath, { force: true })
rmSync(canaryLedgerPath, { force: true })
rmSync(signalLedgerPath, { force: true })
})
test.afterAll(() => rmSync(fakeCliDir, { recursive: true, force: true }))
test('adopts runtime-owned agent and Setup PTYs on first mount', async ({
electronApp,
orcaPage,
registerPostElectronShutdownCleanup
}) => {
const sourceRepo = createSourceRepo()
let createdWorktreePath: string | null = null
registerPostElectronShutdownCleanup(async () => {
if (createdWorktreePath) {
rmSync(createdWorktreePath, { recursive: true, force: true })
}
rmSync(sourceRepo, { recursive: true, force: true })
})
await waitForSessionReady(orcaPage)
await installTerminalPtyWriteSpy(electronApp)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000, null, null)
const added = await client.call<{ repo: { id: string } }>('repo.add', {
path: sourceRepo,
kind: 'git'
})
const repoId = added.result.repo.id
await expect
.poll(() =>
orcaPage.evaluate(async (repoId) => {
const state = window.__store?.getState()
await state?.fetchRepos()
const repo = window.__store?.getState().repos.find((candidate) => candidate.id === repoId)
if (!repo) {
return false
}
await window.__store?.getState().updateRepo(repoId, {
hookSettings: { ...repo.hookSettings, setupAgentStartupPolicy: 'start-immediately' }
})
await window.__store?.getState().updateSettings({
disabledTuiAgents: [],
setupScriptLaunchMode: 'new-tab',
terminalHiddenViewParking: false
})
return true
}, repoId)
)
.toBe(true)
const created = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: `id:${repoId}`,
name: `live-mount-${randomUUID()}`,
noParent: true,
activate: false,
setupDecision: 'run',
startupAgent: 'codex',
startupPrompt: 'keep running'
})
const worktreeId = created.result.worktree.id
createdWorktreePath = created.result.worktree.path
const createdCanary = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: `id:${worktreeId}`,
title: 'Unrelated canary',
command: 'node canary-live.js'
})
let originals: RuntimeTerminalSummary[] = []
await expect
.poll(async () => {
originals = await readWorktreeTerminals(client, worktreeId)
return originals.map(({ connected, writable }) => ({ connected, writable }))
})
.toEqual([
{ connected: true, writable: true },
{ connected: true, writable: true },
{ connected: true, writable: true }
])
expect(
originals.every(
({ incarnationId, ptyId }) =>
typeof incarnationId === 'string' && incarnationId.length > 0 && typeof ptyId === 'string'
)
).toBe(true)
expect(new Set(originals.map((terminal) => terminal.ptyId)).size).toBe(3)
expect(new Set(originals.map((terminal) => terminal.incarnationId)).size).toBe(3)
expect(new Set(originals.map(({ leafId, tabId }) => makePaneKey(tabId, leafId))).size).toBe(3)
const agent = originals.find((terminal) => terminal.handle === created.result.agentTerminalHandle)
const canary = originals.find(
(terminal) => terminal.handle === createdCanary.result.terminal.handle
)
const setup = originals.find(
(terminal) => terminal.handle !== agent?.handle && terminal.handle !== canary?.handle
)
expect(agent).toBeTruthy()
expect(setup).toBeTruthy()
expect(canary).toBeTruthy()
await expect.poll(readSpawnLedger).toHaveLength(1)
await expect.poll(() => readJsonLines<{ pid: number }>(setupLedgerPath)).toHaveLength(1)
await expect.poll(() => readJsonLines<{ pid: number }>(canaryLedgerPath)).toHaveLength(1)
const agentPid = readSpawnLedger()[0]!.pid
const setupPid = readJsonLines<{ pid: number }>(setupLedgerPath)[0]!.pid
const canaryPid = readJsonLines<{ pid: number }>(canaryLedgerPath)[0]!.pid
await expect
.poll(() => terminalOutput(client, agent!.handle))
.toContain(`LIVE_AGENT_READY:${agentPid}`)
await expect
.poll(() => terminalOutput(client, setup!.handle))
.toContain(`SETUP_READY:${setupPid}`)
await expect
.poll(() => terminalOutput(client, canary!.handle))
.toContain(`CANARY_READY:${canaryPid}`)
await assertLaunchLedgersUnchanged()
const beforeStatus = await client.call<RuntimeStatus>('status.get')
expect(beforeStatus.result.graphStatus).toBe('ready')
const daemonPid = readDaemonPid(userDataDir)
const allIdentities = originals.map(terminalIdentity)
await assertTargetBindings(orcaPage, worktreeId, allIdentities)
await seedAgentRecoveryMetadata(orcaPage, worktreeId, terminalIdentity(agent!))
await faultProjectionAndActivate(orcaPage, worktreeId, [agent!, setup!], agent!.tabId)
const mountedAgentPtyId = await waitForActivePanePtyId(orcaPage)
await enableTerminalAccessibility(orcaPage, agent!.tabId)
await expect
.poll(
async () => ({
mountedPtyId: mountedAgentPtyId,
liveInventory: (await readWorktreeTerminals(client, worktreeId)).map(liveTerminalIdentity),
visibleOriginalReady: (
await terminalAccessibility(orcaPage, agent!.tabId).innerText()
).includes(`LIVE_AGENT_READY:${agentPid}`),
processPids: {
agent: readSpawnLedger().map(({ pid }) => pid),
setup: readJsonLines<{ pid: number }>(setupLedgerPath).map(({ pid }) => pid),
canary: readJsonLines<{ pid: number }>(canaryLedgerPath).map(({ pid }) => pid)
}
}),
{ timeout: 10_000 }
)
.toEqual({
mountedPtyId: agent!.ptyId,
liveInventory: originals.map(liveTerminalIdentity),
visibleOriginalReady: true,
processPids: { agent: [agentPid], setup: [setupPid], canary: [canaryPid] }
})
const agentMarker = `AGENT_KB_${randomUUID().slice(0, 8)}`
await clearTerminalPtyWriteLog(electronApp)
await typeIntoTerminal(orcaPage, agent!.tabId, agentMarker)
await assertExactPtyReceivedMarker(electronApp, agent!.ptyId, agentMarker)
await expect(terminalAccessibility(orcaPage, agent!.tabId)).toContainText(
`AGENT_INPUT:${agentPid}:${agentMarker}`
)
await expect(terminalAccessibility(orcaPage, agent!.tabId)).not.toContainText(
'Conversation interrupted'
)
await activateTerminal(orcaPage, worktreeId, setup!.tabId)
const mountedSetupPtyId = await waitForActivePanePtyId(orcaPage)
await enableTerminalAccessibility(orcaPage, setup!.tabId)
expect(mountedSetupPtyId).toBe(setup!.ptyId)
await expect(terminalAccessibility(orcaPage, setup!.tabId)).toContainText(
`SETUP_READY:${setupPid}`
)
const setupMarker = `SETUP_KB_${randomUUID().slice(0, 8)}`
await clearTerminalPtyWriteLog(electronApp)
await typeIntoTerminal(orcaPage, setup!.tabId, setupMarker)
await assertExactPtyReceivedMarker(electronApp, setup!.ptyId, setupMarker)
await expect(terminalAccessibility(orcaPage, setup!.tabId)).toContainText(
`SETUP_INPUT:${setupPid}:${setupMarker}`
)
await expect(terminalAccessibility(orcaPage, setup!.tabId)).not.toContainText(
'Conversation interrupted'
)
const canaryMarker = `CANARY_DIRECT_${randomUUID()}`
await client.call('terminal.send', {
terminal: canary!.handle,
text: canaryMarker,
enter: true
})
await expect
.poll(() => terminalOutput(client, canary!.handle))
.toContain(`CANARY_INPUT:${canaryPid}:${canaryMarker}`)
await assertLiveInventory(client, worktreeId, originals)
await assertTargetBindings(orcaPage, worktreeId, allIdentities)
await assertLaunchLedgersUnchanged()
await assertNoInterruption(client, [agent!, setup!])
expect(readJsonLines(signalLedgerPath)).toHaveLength(0)
const afterMountStatus = await client.call<RuntimeStatus>('status.get')
expect(afterMountStatus.result).toMatchObject({
runtimeId: beforeStatus.result.runtimeId,
rendererGraphEpoch: beforeStatus.result.rendererGraphEpoch,
graphStatus: 'ready',
authoritativeWindowId: beforeStatus.result.authoritativeWindowId
})
expect(readDaemonPid(userDataDir)).toBe(daemonPid)
const beforeReloadDelivery = await orcaPage.evaluate(() =>
window.api.pty.getRendererDeliveryDebugSnapshot()
)
await orcaPage.reload()
await waitForSessionReady(orcaPage)
await expect
.poll(
async () => {
const status = (await client.call<RuntimeStatus>('status.get')).result
return {
runtimeId: status.runtimeId,
rendererGraphEpoch: status.rendererGraphEpoch,
graphStatus: status.graphStatus,
authoritativeWindowId: status.authoritativeWindowId,
daemonPid: readDaemonPid(userDataDir)
}
},
{ timeout: 15_000 }
)
.toEqual({
runtimeId: beforeStatus.result.runtimeId,
rendererGraphEpoch: afterMountStatus.result.rendererGraphEpoch + 1,
graphStatus: 'ready',
authoritativeWindowId: beforeStatus.result.authoritativeWindowId,
daemonPid
})
const postReloadDelivery = {
rendererLifecycleResetCount: beforeReloadDelivery.rendererLifecycleResetCount + 1,
rendererPtyDispatcherReady: true,
rendererDispatcherReadyForcedCount: beforeReloadDelivery.rendererDispatcherReadyForcedCount
}
await expect
.poll(() => orcaPage.evaluate(() => window.api.pty.getRendererDeliveryDebugSnapshot()))
.toMatchObject(postReloadDelivery)
await activateTerminal(orcaPage, worktreeId, agent!.tabId)
const remountedAgentPtyId = await waitForActivePanePtyId(orcaPage)
expect(remountedAgentPtyId).toBe(agent!.ptyId)
await enableTerminalAccessibility(orcaPage, agent!.tabId)
await expect
.poll(() => orcaPage.evaluate(() => window.api.pty.getRendererDeliveryDebugSnapshot()))
.toMatchObject(postReloadDelivery)
const remountAgentLiveMarker = `AGENT_LIVE_${randomUUID()}`
await client.call('terminal.send', {
terminal: agent!.handle,
text: remountAgentLiveMarker,
enter: true
})
const remountAgentLiveOutput = `AGENT_INPUT:${agentPid}:${remountAgentLiveMarker}`
await expect.poll(() => terminalOutput(client, agent!.handle)).toContain(remountAgentLiveOutput)
await expect
.poll(() => terminalViewportText(orcaPage, agent!.tabId))
.toContain(remountAgentLiveOutput)
expect(
await orcaPage.evaluate(() => window.api.pty.getRendererDeliveryDebugSnapshot())
).toMatchObject(postReloadDelivery)
const remountAgentAcceptedMarker = `AGENT_ACCEPTED_${randomUUID()}`
expect(
await orcaPage.evaluate(
({ marker, ptyId }) => window.api.pty.writeAccepted(ptyId, `${marker}\r`),
{ marker: remountAgentAcceptedMarker, ptyId: agent!.ptyId }
)
).toBe(true)
const remountAgentAcceptedOutput = `AGENT_INPUT:${agentPid}:${remountAgentAcceptedMarker}`
await expect
.poll(() => terminalOutput(client, agent!.handle))
.toContain(remountAgentAcceptedOutput)
await expect
.poll(() => terminalViewportText(orcaPage, agent!.tabId))
.toContain(remountAgentAcceptedOutput)
const remountAgentMarker = `AGENT_REMOUNT_${randomUUID().slice(0, 8)}`
await clearTerminalPtyWriteLog(electronApp)
await typeIntoTerminal(orcaPage, agent!.tabId, remountAgentMarker)
await assertExactPtyReceivedMarker(electronApp, agent!.ptyId, remountAgentMarker)
const remountAgentOutput = `AGENT_INPUT:${agentPid}:${remountAgentMarker}`
await expect.poll(() => terminalOutput(client, agent!.handle)).toContain(remountAgentOutput)
await expect
.poll(() => terminalViewportText(orcaPage, agent!.tabId))
.toContain(remountAgentOutput)
await activateTerminal(orcaPage, worktreeId, setup!.tabId)
const remountedSetupPtyId = await waitForActivePanePtyId(orcaPage)
expect(remountedSetupPtyId).toBe(setup!.ptyId)
await enableTerminalAccessibility(orcaPage, setup!.tabId)
const remountSetupLiveMarker = `SETUP_LIVE_${randomUUID()}`
await client.call('terminal.send', {
terminal: setup!.handle,
text: remountSetupLiveMarker,
enter: true
})
const remountSetupLiveOutput = `SETUP_INPUT:${setupPid}:${remountSetupLiveMarker}`
await expect.poll(() => terminalOutput(client, setup!.handle)).toContain(remountSetupLiveOutput)
await expect
.poll(() => terminalViewportText(orcaPage, setup!.tabId))
.toContain(remountSetupLiveOutput)
expect(
await orcaPage.evaluate(() => window.api.pty.getRendererDeliveryDebugSnapshot())
).toMatchObject(postReloadDelivery)
const remountSetupMarker = `SETUP_REMOUNT_${randomUUID().slice(0, 8)}`
await clearTerminalPtyWriteLog(electronApp)
await typeIntoTerminal(orcaPage, setup!.tabId, remountSetupMarker)
await assertExactPtyReceivedMarker(electronApp, setup!.ptyId, remountSetupMarker)
const remountSetupOutput = `SETUP_INPUT:${setupPid}:${remountSetupMarker}`
await expect.poll(() => terminalOutput(client, setup!.handle)).toContain(remountSetupOutput)
await expect
.poll(() => terminalViewportText(orcaPage, setup!.tabId))
.toContain(remountSetupOutput)
const remountCanaryMarker = `CANARY_REMOUNT_${randomUUID()}`
await client.call('terminal.send', {
terminal: canary!.handle,
text: remountCanaryMarker,
enter: true
})
await expect
.poll(() => terminalOutput(client, canary!.handle))
.toContain(`CANARY_INPUT:${canaryPid}:${remountCanaryMarker}`)
await assertLiveInventory(client, worktreeId, originals)
await assertTargetBindings(orcaPage, worktreeId, allIdentities)
await assertLaunchLedgersUnchanged()
await assertNoInterruption(client, [agent!, setup!])
expect(readJsonLines(signalLedgerPath)).toHaveLength(0)
})