From cd68a8b00cbd595c5ddc01c4117d5366dd2b2d4b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 3 Aug 2026 11:11:14 -0700 Subject: [PATCH] fix: preserve live agent PTYs through graph hydration (#11789) --- config/reliability-gates.jsonc | 106 +- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 6 +- src/main/daemon/daemon-protocol-version.ts | 7 +- src/main/daemon/daemon-pty-adapter.test.ts | 63 + src/main/daemon/daemon-pty-adapter.ts | 13 +- .../daemon/daemon-server-attach-only.test.ts | 76 + src/main/daemon/daemon-server.ts | 5 +- .../terminal-host-agent-session-claim.ts | 1 - .../daemon/terminal-host-attach-only.test.ts | 68 + .../daemon/terminal-host-create-contract.ts | 2 + src/main/daemon/types.ts | 2 + src/main/ipc/pty.test.ts | 1022 ++++++++++- src/main/ipc/pty.ts | 1527 ++++++++++++----- src/main/providers/local-pty-provider.test.ts | 49 + src/main/providers/local-pty-provider.ts | 12 +- src/main/providers/pty-provider-contract.ts | 2 + src/main/runtime/orca-runtime.test.ts | 103 ++ src/main/runtime/orca-runtime.ts | 538 +++--- .../terminal-pane/pty-connection.test.ts | 72 +- .../terminal-pane/pty-connection.ts | 38 + .../terminal-pane/pty-transport-types.ts | 2 + .../terminal-pane/pty-transport.test.ts | 112 ++ .../components/terminal-pane/pty-transport.ts | 3 + .../remote-runtime-pty-transport.ts | 12 +- .../pane-fit-continuation-retry.ts | 17 +- .../src/lib/pane-manager/pane-fit.test.ts | 13 + .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/runtime-types.ts | 3 + ...ackground-terminal-mount-authority.spec.ts | 825 +++++++++ 31 files changed, 3941 insertions(+), 769 deletions(-) create mode 100644 src/main/daemon/daemon-server-attach-only.test.ts create mode 100644 src/main/daemon/terminal-host-attach-only.test.ts create mode 100644 tests/e2e/live-background-terminal-mount-authority.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 343ff1c6a..99eeed20f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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, diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index a9e5285c8..c2bbc8bfa 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -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) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index db00c3750..aa8b58e11 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -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) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 5a7f8bb99..1f4195af3 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -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 { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 5a3d766b8..7f6960edf 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ef60ca5d9..12f805ffc 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 { + 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(), @@ -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, diff --git a/src/main/daemon/daemon-server-attach-only.test.ts b/src/main/daemon/daemon-server-attach-only.test.ts new file mode 100644 index 000000000..b235b5474 --- /dev/null +++ b/src/main/daemon/daemon-server-attach-only.test.ts @@ -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() + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 0d8123ce1..4c33344fa 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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, diff --git a/src/main/daemon/terminal-host-agent-session-claim.ts b/src/main/daemon/terminal-host-agent-session-claim.ts index b9fc70cf4..cea1c925d 100644 --- a/src/main/daemon/terminal-host-agent-session-claim.ts +++ b/src/main/daemon/terminal-host-agent-session-claim.ts @@ -4,7 +4,6 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { agentSessionGeneration?: string - attachOnly?: boolean } export async function createOrAttachClaimedAgentSession(args: { diff --git a/src/main/daemon/terminal-host-attach-only.test.ts b/src/main/daemon/terminal-host-attach-only.test.ts new file mode 100644 index 000000000..b99dd40b6 --- /dev/null +++ b/src/main/daemon/terminal-host-attach-only.test.ts @@ -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 + + beforeEach(() => { + spawnSubprocess = vi.fn(() => { + 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() + }) +}) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 3a3e9b1c9..114aae91a 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -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 diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index bc0be4070..865572ea0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 0d6d50cd0..04b6e7b80 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -303,9 +303,7 @@ describe('registerPtyHandlers', () => { webContents: { on: vi.fn(), send: vi.fn(), - removeListener: vi.fn(), - // Why: the did-start-loading reset handler filters to main-frame loads; default true so lifecycle-reset tests reset (a subframe case overrides false). - isLoadingMainFrame: vi.fn(() => true) + removeListener: vi.fn() } } const mainWindowIpcEvent = { sender: mainWindow.webContents } @@ -1543,6 +1541,11 @@ describe('registerPtyHandlers', () => { return listenerCall[1] as (...args: unknown[]) => void } + function getMainFrameNavigationListener(): () => void { + const listener = getMainWindowWebContentsListener('did-start-navigation') + return () => listener({ isMainFrame: true, isSameDocument: false }) + } + function getPtyResizeListener(): ( event: unknown, args: { id: string; cols: number; rows: number } @@ -8177,7 +8180,7 @@ describe('registerPtyHandlers', () => { resolveSpawn({ id: 'pty-shared' }) await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([ { id: 'pty-shared' }, - { id: 'pty-shared' } + { id: 'pty-shared', isReattach: true } ]) expect(providerSpawn).toHaveBeenCalledTimes(1) expect(store.persistPtyBinding).toHaveBeenCalledWith({ @@ -8189,6 +8192,83 @@ describe('registerPtyHandlers', () => { }) }) + it('waits for an early runtime pane claim before renderer creation', async () => { + type RuntimeSpawnController = { + claimStablePaneCreate(args: { + worktreeId: string + connectionId: string | null + tabId: string + leafId: string + }): () => void + } + const providerSpawn = vi.fn(async () => ({ id: 'pty-after-runtime-claim' })) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_claimed'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const tabId = 'tab-early-runtime-claim' + const leafId = '44444444-4444-4444-8444-444444444444' + const worktreeId = 'repo-1::/tmp/early-runtime-claim' + const releaseClaim = (controller as unknown as RuntimeSpawnController).claimStablePaneCreate({ + worktreeId, + connectionId: null, + tabId, + leafId + }) + + const mounted = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/early-runtime-claim', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: makePaneKey(tabId, leafId), + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(providerSpawn).not.toHaveBeenCalled() + + releaseClaim() + await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) + expect(providerSpawn).toHaveBeenCalledOnce() + }) + it('reuses renderer spawn when runtime materialization starts for the same pane', async () => { type RuntimeSpawnController = { spawn(args: { @@ -8235,6 +8315,7 @@ describe('registerPtyHandlers', () => { const store = { persistPtyBinding: vi.fn() } + let registeredPane: { ptyId: string; tabId: string; leafId: string } | null = null let controller: RuntimeSpawnController | null = null const runtime = { setPtyController: vi.fn((value) => { @@ -8243,7 +8324,30 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), - registerPty: vi.fn(), + registerPty: vi.fn( + ( + ptyId: string, + _worktreeId: string, + _connectionId: string | null, + binding?: { tabId: string; leafId: string } + ) => { + if (binding) { + registeredPane = { ptyId, ...binding } + } + } + ), + resolveTerminalPane: vi.fn(() => { + if (!registeredPane) { + throw new Error('terminal_not_found') + } + return { + handle: 'term_trusted', + tabId: registeredPane.tabId, + leafId: registeredPane.leafId, + ptyId: registeredPane.ptyId, + worktreeId: 'repo-1::/tmp' + } + }), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -8285,14 +8389,18 @@ describe('registerPtyHandlers', () => { env: { ORCA_PANE_KEY: paneKey }, persistHostSessionBinding: true }) - await Promise.resolve() - - expect(providerSpawn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(1)) resolveSpawn({ id: 'pty-renderer' }) - await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([ - { id: 'pty-renderer' }, - { id: 'pty-renderer' } - ]) + const [rendererResult, runtimeResult] = await Promise.all([rendererSpawn, runtimeSpawn]) + expect(rendererResult).toEqual({ id: 'pty-renderer' }) + expect(runtimeResult).toEqual({ + id: 'pty-renderer', + stablePaneOwner: { + handle: 'term_trusted', + tabId: 'tab-race', + leafId + } + }) expect(providerSpawn).toHaveBeenCalledTimes(1) expect(store.persistPtyBinding).toHaveBeenCalledWith({ worktreeId: 'repo-1::/tmp', @@ -8303,6 +8411,825 @@ describe('registerPtyHandlers', () => { }) }) + it.each([ + { + label: 'git worktree', + worktreeId: 'repo-1::/tmp/live-owner', + cwd: '/tmp/live-owner' + }, + { + label: 'folder workspace', + worktreeId: 'folder:live-owner', + cwd: '/tmp' + } + ])( + 'adopts a completed runtime-owned pane before replacement launch preflight ($label)', + async ({ worktreeId, cwd }) => { + type StableAdoption = { + result: { id: string; incarnationId?: string; isReattach?: boolean } + owner: { handle?: string; tabId: string; leafId: string; ptyId: string } + materialized?: true + } | null + type RuntimeSpawnController = { + adoptStablePane(args: { + cols: number + rows: number + worktreeId: string + tabId: string + leafId: string + cwd: string + }): Promise + spawn(args: Record): Promise<{ + id: string + incarnationId?: string + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + }> + } + const tabId = 'tab-live-owner' + const leafId = '66666666-6666-4666-8666-666666666666' + const paneKey = makePaneKey(tabId, leafId) + let ownerPublished = false + let releaseAttach!: () => void + let attachBarrier: Promise + const resetAttachBarrier = (): void => { + attachBarrier = new Promise((resolve) => { + releaseAttach = resolve + }) + } + resetAttachBarrier() + const supportsAgentSessionClaims = vi.fn(async () => false) + const supportsAgentSessionCreateOperations = vi.fn(async () => false) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + await attachBarrier + return { + id: 'pty-live-owner', + incarnationId: 'inc-live-owner', + isReattach: true, + snapshot: 'original-live-output', + providerSequence: { value: 20, generation: 'continued' as const } + } + } + return { id: 'pty-live-owner', incarnationId: 'inc-live-owner' } + } + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + supportsAgentSessionClaims, + supportsAgentSessionCreateOperations, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const folderWorkspace = { + id: 'live-owner', + folderPath: cwd, + projectGroupId: 'folder-group' + } + const store = { + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => folderWorkspace), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const prepareClaudeAuth = vi.fn(() => { + throw new Error('replacement auth preflight must not run') + }) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + resolveTerminalPane: vi.fn(() => { + if (!ownerPublished) { + throw new Error('terminal_not_found') + } + return { + handle: 'term-live-owner', + tabId, + leafId, + ptyId: 'pty-live-owner', + worktreeId + } + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-provisional-renderer'), + preAllocateHandleForPty: vi.fn(() => 'term-live-owner'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth, + store as never + ) + const spawnController = controller as unknown as RuntimeSpawnController + await spawnController.spawn({ + cols: 80, + rows: 24, + cwd, + command: 'node original-agent-fixture.mjs', + worktreeId, + preAllocatedHandle: 'term-live-owner', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey }, + persistHostSessionBinding: true + }) + ownerPublished = true + runtime.createPreAllocatedTerminalHandle.mockClear() + runtime.registerPreAllocatedHandleForPty.mockClear() + runtime.noteTerminalSpawnCommand.mockClear() + trackMock.mockClear() + store.persistPtyBinding.mockClear() + mainWindow.webContents.send.mockClear() + + const mountArgs = { + cols: 120, + rows: 40, + cwd, + command: 'claude --resume provider-session', + launchAgent: 'claude', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + }, + telemetry: { + agent_kind: 'codex', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + const firstMount = handlers.get('pty:spawn')!(null, mountArgs) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(2)) + const secondMount = handlers.get('pty:spawn')!(null, mountArgs) + releaseAttach() + const [mounted, concurrentMounted] = await Promise.all([firstMount, secondMount]) + + expect(mounted).toMatchObject({ + id: 'pty-live-owner', + incarnationId: 'inc-live-owner', + isReattach: true, + snapshot: 'original-live-output' + }) + expect(concurrentMounted).toEqual(mounted) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ + attachOnly: true, + sessionId: 'pty-live-owner' + }) + expect(providerSpawn.mock.calls[1]?.[0].command).toBeUndefined() + expect(runtime.createPreAllocatedTerminalHandle).not.toHaveBeenCalled() + expect(prepareClaudeAuth).not.toHaveBeenCalled() + expect(runtime.registerPreAllocatedHandleForPty).not.toHaveBeenCalled() + expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + expect(getPtyIdForPaneKey(paneKey)).toBe('pty-live-owner') + + resetAttachBarrier() + store.persistPtyBinding.mockClear() + mainWindow.webContents.send.mockClear() + const adoptionArgs = { cols: 120, rows: 40, cwd, worktreeId, tabId, leafId } + let runtimeSecondAdoption: Promise | null = null + runtime.beginPtyRegistration.mockImplementation(() => { + runtimeSecondAdoption ??= spawnController.adoptStablePane(adoptionArgs) + }) + const rendererFirstMount = handlers.get('pty:spawn')!(null, mountArgs) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(3)) + releaseAttach() + await vi.waitFor(() => expect(runtimeSecondAdoption).not.toBeNull()) + const pendingRuntimeAdoption = runtimeSecondAdoption + if (!pendingRuntimeAdoption) { + throw new Error('runtime adoption did not enter during renderer publication') + } + const adoptedOwner = await pendingRuntimeAdoption + expect(adoptedOwner).toMatchObject({ materialized: true }) + + const claimedResultPromise = spawnController.spawn({ + cols: 120, + rows: 40, + cwd, + command: 'codex resume should-not-run', + worktreeId, + preAllocatedHandle: 'term-live-owner', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey }, + persistHostSessionBinding: true, + adoptedStablePane: adoptedOwner, + agentSessionEnsure: { + claim: { + ...recoveredAgentClaim, + identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' + }, + surface: { worktreeId, tabId, leafId, terminalHandle: 'term-live-owner' } + }, + agentSessionCreateOperationId: 'create-op-must-not-run' + }) + const [rendererFirstResult, claimedResult] = await Promise.all([ + rendererFirstMount, + claimedResultPromise + ]) + expect(rendererFirstResult).toMatchObject({ + id: 'pty-live-owner', + incarnationId: 'inc-live-owner', + isReattach: true + }) + expect(claimedResult).toMatchObject({ + id: 'pty-live-owner', + stablePaneOwner: { handle: 'term-live-owner', tabId, leafId } + }) + expect(providerSpawn).toHaveBeenCalledTimes(3) + expect(supportsAgentSessionClaims).not.toHaveBeenCalled() + expect(supportsAgentSessionCreateOperations).not.toHaveBeenCalled() + expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect( + mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned') + ).toHaveLength(1) + } + ) + + it('adopts an exact persisted owner when the runtime projection is missing', async () => { + const tabId = 'tab-persisted-owner' + const leafId = '88888888-8888-4888-8888-888888888888' + const paneKey = makePaneKey(tabId, leafId) + const worktreeId = 'repo-1::/tmp/persisted-owner' + const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({ + id: options.sessionId ?? 'unexpected-fresh-id', + incarnationId: 'inc-persisted-owner', + isReattach: options.attachOnly === true, + snapshot: 'persisted-owner-output' + })) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-rebuilt-owner'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtyExit: vi.fn() + } + const store = { + getWorkspaceSession: vi.fn(() => ({ + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-persisted-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } } + }, + terminalPtyIncarnationsByPaneKey: { + [paneKey]: 'inc-persisted-owner' + } + })), + persistPtyBinding: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/persisted-owner', + command: 'codex resume provider-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(mounted).toMatchObject({ + id: 'pty-persisted-owner', + incarnationId: 'inc-persisted-owner', + isReattach: true + }) + expect(providerSpawn).toHaveBeenCalledOnce() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + attachOnly: true, + sessionId: 'pty-persisted-owner', + command: undefined + }) + ) + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + 'pty-persisted-owner', + 'term-rebuilt-owner' + ) + expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled() + expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect( + mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned') + ).toHaveLength(1) + expect(runtime.onPtyExit).not.toHaveBeenCalled() + }) + + it.each([ + { + label: 'git worktree', + worktreeId: 'repo-1::/tmp/dead-persisted-owner', + cwd: '/tmp/dead-persisted-owner', + folderMissing: false + }, + { + label: 'missing folder workspace', + worktreeId: 'folder:dead-persisted-owner', + cwd: '/tmp/missing-dead-persisted-owner', + folderMissing: true + } + ])( + 'retires a persistence-only dead owner before fresh recovery ($label)', + async ({ worktreeId, cwd, folderMissing }) => { + const tabId = 'tab-dead-persisted-owner' + const leafId = '12121212-1212-4212-8212-121212121212' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + throw new Error('Session not found: pty-dead-persisted-owner') + } + return { id: 'pty-fresh-recovery', incarnationId: 'inc-fresh-recovery' } + } + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-dead-persisted-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-dead-persisted-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-persisted-owner' } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => ({ + id: 'dead-persisted-owner', + folderPath: cwd, + projectGroupId: 'folder-group' + })), + getFolderWorkspaces: vi.fn(() => [ + { + id: 'dead-persisted-owner', + folderPath: cwd, + projectGroupId: 'folder-group' + } + ]), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-fresh-recovery'), + preAllocateHandleForPty: vi.fn(() => 'term-fresh-recovery'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + if (folderMissing) { + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('missing folder'), { code: 'ENOENT' }) + }) + } + const mountedPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume exact-dead-provider-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + if (folderMissing) { + await expect(mountedPromise).rejects.toThrow(`folder_workspace_path_missing:${cwd}`) + expect(providerSpawn).toHaveBeenCalledOnce() + expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ + attachOnly: true, + sessionId: 'pty-dead-persisted-owner', + command: undefined + }) + expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-dead-persisted-owner', + 0, + 'inc-dead-persisted-owner' + ) + return + } + const mounted = await mountedPromise + + expect(mounted).toMatchObject({ + id: 'pty-fresh-recovery', + incarnationId: 'inc-fresh-recovery' + }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ + attachOnly: true, + sessionId: 'pty-dead-persisted-owner', + command: undefined + }) + expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ + command: 'codex resume exact-dead-provider-session' + }) + expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.flushOrThrow).toHaveBeenCalledOnce() + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-dead-persisted-owner', + 0, + 'inc-dead-persisted-owner' + ) + } + ) + + it('retires a dead owner from the exact SSH host session before fresh recovery', async () => { + const connectionId = 'ssh-dead-stable-pane' + const hostId = `ssh:${connectionId}` + const tabId = 'tab-dead-ssh-owner' + const leafId = '34343434-3434-4434-8434-343434343434' + const paneKey = makePaneKey(tabId, leafId) + const worktreeId = 'repo-ssh::/remote/dead-stable-pane' + const deadPtyId = `ssh:${connectionId}@@dead-relay-pty` + const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty` + const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => { + if (options.attachOnly) { + throw new Error('PTY "dead-relay-pty" not found') + } + return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' } + }) + registerSshPtyProvider(connectionId, { + spawn: remoteSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: deadPtyId }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: deadPtyId } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' } + } + const store = { + getWorkspaceSession: vi.fn((requestedHostId?: string) => { + expect(requestedHostId).toBe(hostId) + return session + }), + setWorkspaceSession: vi.fn((next, requestedHostId?: string) => { + expect(requestedHostId).toBe(hostId) + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + upsertSshRemotePtyLease: vi.fn(), + removeSshRemotePtyLease: vi.fn(), + markSshRemotePtyLease: vi.fn() + } + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-fresh-ssh-owner'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/remote/dead-stable-pane', + command: 'codex resume exact-dead-ssh-provider-session', + connectionId, + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(mounted).toMatchObject({ id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' }) + expect(remoteSpawn).toHaveBeenCalledTimes(2) + expect(remoteSpawn.mock.calls[0]?.[0]).toMatchObject({ + attachOnly: true, + sessionId: deadPtyId, + command: undefined + }) + expect(remoteSpawn.mock.calls[1]?.[0]).toMatchObject({ + command: 'codex resume exact-dead-ssh-provider-session' + }) + expect(store.setWorkspaceSession).toHaveBeenCalledWith(expect.anything(), hostId) + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId, + tabId, + leafId, + ptyId: freshPtyId + }), + hostId + ) + } finally { + unregisterSshPtyProvider(connectionId) + } + }) + + it('fails closed when runtime and persisted stable-pane owners conflict', async () => { + const tabId = 'tab-conflicting-owner' + const leafId = '99999999-9999-4999-8999-999999999999' + const paneKey = makePaneKey(tabId, leafId) + const worktreeId = 'repo-1::/tmp/conflicting-owner' + const providerSpawn = vi.fn() + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => ({ + handle: 'term-runtime-owner', + tabId, + leafId, + ptyId: 'pty-runtime-owner', + worktreeId + })), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-provisional') + } + const store = { + getWorkspaceSession: vi.fn(() => ({ + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-persisted-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } } + } + })) + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/conflicting-owner', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + ).rejects.toThrow('terminal_pane_owner_conflict') + expect(providerSpawn).not.toHaveBeenCalled() + }) + + it('does not coalesce identical pane coordinates across worktrees', async () => { + const providerSpawn = vi.fn(async () => ({ id: `pty-${providerSpawn.mock.calls.length}` })) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + const leafId = '77777777-7777-4777-8777-777777777777' + const paneKey = makePaneKey('tab-host-scope', leafId) + const spawn = (worktreeId: string) => + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + worktreeId, + tabId: 'tab-host-scope', + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-host-scope', + ORCA_WORKTREE_ID: worktreeId + } + }) + + await Promise.all([spawn('repo-1::/tmp/a'), spawn('repo-1::/tmp/b')]) + + expect(providerSpawn).toHaveBeenCalledTimes(2) + }) + it('settles the pane reservation when a post-spawn step throws so later spawns do not hang', async () => { // Why: reservation-leak regression — a post-spawn throw after provider.spawn resolves must reject/clear the reservation, else later spawns for the same pane key hang forever. registerPtyHandlers(mainWindow as never) @@ -8807,7 +9734,7 @@ describe('registerPtyHandlers', () => { } }) - it('does not leave SSH leases when runtime-owned binding persistence fails after reattach', async () => { + it('preserves adopted SSH ownership when runtime binding persistence fails', async () => { type RuntimeSpawnController = { spawn(args: { cols: number @@ -8895,9 +9822,12 @@ describe('registerPtyHandlers', () => { expect(remoteShutdown).not.toHaveBeenCalled() getPtyWriteListener()(mainWindowIpcEvent, { id: 'ssh:ssh-reattach-fail@@relay-pty', - data: 'echo should-not-route' + data: 'echo remains-routable' }) - expect(remoteWrite).not.toHaveBeenCalled() + expect(remoteWrite).toHaveBeenCalledWith( + 'ssh:ssh-reattach-fail@@relay-pty', + 'echo remains-routable' + ) unregisterSshPtyProvider('ssh-reattach-fail') }) @@ -10595,7 +11525,7 @@ describe('registerPtyHandlers', () => { cwd: '/tmp' })) as { id: string } const setRendererPtyVisible = getPtySetRendererPtyVisibleListener() - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() mainWindow.webContents.send.mockClear() @@ -10638,7 +11568,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() // Drain the initial dispatcher-ready flush (beforeEach fires the handshake to model a live page) so flood timing starts clean. vi.advanceTimersByTime(1) @@ -10703,7 +11633,7 @@ describe('registerPtyHandlers', () => { } }) - it('ignores a subframe did-start-loading (isLoadingMainFrame false) so an in-page iframe load cannot freeze delivery', async () => { + it('ignores overlapping subframe navigation so an in-page iframe cannot reclose delivery', async () => { vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -10715,8 +11645,8 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') - const ackData = getPtyAckDataListener() + const handleRendererNavigation = getMainWindowWebContentsListener('did-start-navigation') + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() // Drain the initial dispatcher-ready flush (beforeEach fires the handshake). vi.advanceTimersByTime(1) mainWindow.webContents.send.mockClear() @@ -10729,23 +11659,31 @@ describe('registerPtyHandlers', () => { } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - // A subframe load (isLoadingMainFrame() === false, e.g. notebook srcDoc iframe) is NOT a lifecycle reset — resetting here would freeze every pane for the watchdog window. - mainWindow.webContents.isLoadingMainFrame.mockReturnValueOnce(false) - handleRendererLoading() + // Main navigation closes the gate; the fresh dispatcher reopens it before an overlapping iframe navigates. + handleRendererNavigation({ isMainFrame: true, isSameDocument: false }) + handleRendererDispatcherReady() + handleRendererNavigation({ isMainFrame: false, isSameDocument: false }) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ - rendererInFlightChars: 512 * 1024, - pendingChars: 88 * 1024, - pendingPtyCount: 1, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, + rendererInFlightChars: 0, + pendingChars: 0, + pendingPtyCount: 0, + rendererLifecycleResetCount: 1, + lastLifecycleResetClearedChars: 512 * 1024, rendererPtyDispatcherReady: true }) - // Gate still open: ACKing the in-flight cap drains the held backlog (a spurious reset would have cleared pending/ready, so this send would never fire). + // Gate remains open: output after the iframe navigation reaches the fresh page without waiting for the watchdog. mainWindow.webContents.send.mockClear() - ackData(null, { id: spawnResult.id, charCount: 512 * 1024 }) + mockProc.emitData('post-subframe output') vi.advanceTimersByTime(8) - expect(mainWindow.webContents.send).toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'post-subframe output' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: true, + rendererDispatcherReadyForcedCount: 0 + }) } finally { vi.useRealTimers() } @@ -10782,7 +11720,7 @@ describe('registerPtyHandlers', () => { rendererPtyDispatcherReady: true }) - // Handshake arriving while the gate is already open proves a missed lifecycle reset (subframe-overlapped reload emits no did-start-loading); it must reconcile stale accounting or survivors stay pinned at the cap forever. + // Handshake while the gate is open proves a missed lifecycle reset; reconcile or survivors stay pinned at the cap. mainWindow.webContents.send.mockClear() handleRendererDispatcherReady() const reconciled = getPtyRendererDeliveryDebugSnapshot() @@ -10823,7 +11761,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() const writeListener = getPtyWriteListener() // Drain the initial ready-flush the beforeEach handshake schedules. @@ -10868,7 +11806,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() vi.advanceTimersByTime(1) // Reload closes the gate and arms the ~10s watchdog; the reloaded page never sends the handshake (dropped IPC), so output stays held. @@ -10910,7 +11848,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() const readyCall = onMock.mock.calls.find( (call: unknown[]) => call[0] === 'pty:rendererDispatcherReady' )! @@ -10951,7 +11889,7 @@ describe('registerPtyHandlers', () => { try { registerPtyHandlers(mainWindow as never) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() vi.advanceTimersByTime(1) @@ -10984,7 +11922,7 @@ describe('registerPtyHandlers', () => { try { registerPtyHandlers(mainWindow as never) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() // Drain the initial dispatcher-ready flush; the baseline is timer-free. vi.advanceTimersByTime(1) expect(vi.getTimerCount()).toBe(0) @@ -11794,7 +12732,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' }) - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() let cleared = false mainWindow.webContents.send.mockImplementation( (channel: string, payload: { id?: string }) => { @@ -12506,7 +13444,7 @@ describe('registerPtyHandlers', () => { try { const provider = installObservableDaemonTestProvider() registerPtyHandlers(mainWindow as never) - const resetRenderer = getMainWindowWebContentsListener('did-start-loading') + const resetRenderer = getMainFrameNavigationListener() const readyRenderer = getPtyRendererDispatcherReadyListener() let failed = false mainWindow.webContents.send.mockImplementation((channel: string) => { @@ -13256,7 +14194,7 @@ describe('registerPtyHandlers', () => { try { await spawnAndSaturateRendererDeliveryGate(mockProc) - const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererLoading = getMainFrameNavigationListener() expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightPtyCount: 1, rendererInFlightChars: 512 * 1024 @@ -14031,7 +14969,7 @@ describe('registerPtyHandlers', () => { const setHidden = getPtySetHiddenRendererPtyListener() const setInterest = getPtySetDeliveryInterestListener() const setActive = getPtySetActiveRendererPtyListener() - getMainWindowWebContentsListener('did-start-loading')() + getMainFrameNavigationListener()() mainWindow.webContents.send.mockClear() setHidden(null, { id: spawnResult.id, hidden: true }) @@ -14119,7 +15057,7 @@ describe('registerPtyHandlers', () => { rows: 24, cwd: '/tmp' })) as { id: string } - getMainWindowWebContentsListener('did-start-loading')() + getMainFrameNavigationListener()() getPtySetHiddenRendererPtyListener()(null, { id: spawnResult.id, hidden: true }) mainWindow.webContents.send.mockClear() mockProc.emitData('blocked while gate disabled') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index d7786a30d..c109a2973 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -14,6 +14,7 @@ import { export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' +import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' import type { GlobalSettings, TuiAgent } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' @@ -317,9 +318,20 @@ type PaneSpawnReservation = { type PaneSpawnReservationResult = { id: string launchConfig?: SleepingAgentLaunchConfig + stablePaneOwner?: { + handle: string + tabId: string + leafId: string + } } & Partial -// Why: mobile materialization and a newly-focused pane can race to spawn the same leaf; key by paneKey so the loser adopts the winner's PTY. -const paneSpawnReservationsByPaneKey = new Map() +// Why: identical pane coordinates on different worktrees or hosts are independent owners. +const paneSpawnReservationsByOwnerKey = new Map() +type PendingRuntimePaneCreate = { + count: number + promise: Promise + resolve: () => void +} +const pendingRuntimePaneCreatesByOwnerKey = new Map() // Why: one main process can route the same remote provider namespace through // multiple SSH relays; coordinate claims above every provider boundary too. const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() @@ -475,16 +487,54 @@ function reservePaneSpawn(paneKey: string): PaneSpawnReservation { }) promise.catch(() => {}) const reservation = { promise, resolve, reject } - paneSpawnReservationsByPaneKey.set(paneKey, reservation) + paneSpawnReservationsByOwnerKey.set(paneKey, reservation) return reservation } function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void { - if (paneSpawnReservationsByPaneKey.get(paneKey) === reservation) { - paneSpawnReservationsByPaneKey.delete(paneKey) + if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { + paneSpawnReservationsByOwnerKey.delete(paneKey) } } +function makePaneSpawnReservationKey( + worktreeId: string | undefined, + connectionId: string | null | undefined, + paneKey: string | null | undefined +): string | null { + return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null +} + +function claimRuntimePaneCreate(ownerKey: string): () => void { + const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) + if (existing) { + existing.count += 1 + return () => releaseRuntimePaneCreate(ownerKey, existing) + } + let resolve!: () => void + const claim = { + count: 1, + promise: new Promise((done) => { + resolve = done + }), + resolve: () => resolve() + } + pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) + return () => releaseRuntimePaneCreate(ownerKey, claim) +} + +function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { + if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { + return + } + claim.count -= 1 + if (claim.count > 0) { + return + } + pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) + claim.resolve() +} + function rejectPaneSpawnReservation( paneKey: string | null | undefined, reservation: PaneSpawnReservation | null | undefined, @@ -514,6 +564,227 @@ function resolvePaneSpawnReservation( return response } +type StablePaneOwner = { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string +} +type StablePaneAdoption = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} | null +const stablePaneAdoptionsByOwnerKey = new Map>() + +function resolvePersistedStablePaneOwner( + store: Store | undefined, + paneKey: string, + worktreeId: string, + connectionId: string | null | undefined +): Pick | null { + if (!store || typeof store.getWorkspaceSession !== 'function') { + return null + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + const session = store.getWorkspaceSession( + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + const tab = session.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId + ) + const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] + if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { + return null + } + const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + return { + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) + } +} + +function resolveStablePaneOwner( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + paneKey: string | null | undefined, + worktreeId: string | undefined, + connectionId: string | null | undefined +): StablePaneOwner | null { + if (!paneKey || !worktreeId) { + return null + } + let resolved: ReturnType | null = null + let resolvedHandleCandidate: ReturnType | null = null + if (runtime && typeof runtime.resolveTerminalPane === 'function') { + try { + const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) + resolvedHandleCandidate = candidate + resolved = candidate.connected === false ? null : candidate + } catch (error) { + if (!(error instanceof Error && error.message === 'terminal_not_found')) { + throw error + } + } + } + const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { + throw new Error('terminal_pane_owner_conflict') + } + const ptyId = resolved?.ptyId ?? persisted?.ptyId + if (!ptyId) { + return null + } + const registeredConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null + if (ownerConnectionId !== (connectionId ?? null)) { + throw new Error('terminal_pane_owner_host_mismatch') + } + const runtimeIncarnationId = ptyIncarnationById.get(ptyId) + if ( + runtimeIncarnationId && + persisted?.incarnationId && + runtimeIncarnationId !== persisted.incarnationId + ) { + throw new Error('terminal_pane_owner_conflict') + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return { + ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), + tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, + leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, + ptyId, + ...(runtimeIncarnationId || persisted?.incarnationId + ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } + : {}) + } +} + +function retirePersistedStablePaneOwner( + store: Store | undefined, + owner: StablePaneOwner, + worktreeId: string, + connectionId: string | null | undefined +): boolean { + if (!store) { + return false + } + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (current?.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { + return false + } + const session = store.getWorkspaceSession(hostId) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + }) + if (retired === session) { + return false + } + store.setWorkspaceSession(retired, hostId) + store.flushOrThrow() + return true +} + +type StablePaneSpawnContext = { + runtime: OrcaRuntimeService | undefined + store?: Store + provider: IPtyProvider + spawnOptions: PtySpawnOptions + owner: StablePaneOwner | null + worktreeId?: string + connectionId?: string | null + resolveOwner?: () => StablePaneOwner | null + onFreshSpawn?: (result: PtySpawnResult) => void +} + +async function attachStablePaneOwner( + args: StablePaneSpawnContext & { owner: StablePaneOwner } +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { + const { owner, provider, runtime, spawnOptions } = args + let result: PtySpawnResult + try { + result = await provider.spawn({ + ...spawnOptions, + sessionId: owner.ptyId, + attachOnly: true, + isNewSession: undefined, + command: undefined, + commandDelivery: undefined, + startupCommandDelivery: undefined, + launchAgent: undefined, + startupIngress: undefined, + agentSessionEnsure: undefined, + agentSessionCreateOperationId: undefined, + onPtySpawnCommitted: undefined + }) + } catch (error) { + if (!isPtyAlreadyGoneError(error)) { + throw error + } + const ownerBeforeRetire = args.resolveOwner?.() + if ( + ownerBeforeRetire && + (ownerBeforeRetire.ptyId !== owner.ptyId || + (ownerBeforeRetire.incarnationId !== undefined && + owner.incarnationId !== undefined && + ownerBeforeRetire.incarnationId !== owner.incarnationId)) + ) { + throw new Error('terminal_pane_owner_changed') + } + runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + clearProviderPtyState(owner.ptyId) + ptyOwnership.delete(owner.ptyId) + if ( + args.worktreeId && + !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (args.resolveOwner?.()) { + throw new Error('terminal_pane_owner_changed') + } + return null + } + if ( + result.id !== owner.ptyId || + result.isReattach !== true || + (owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { result, owner } +} + +async function spawnForStablePane( + args: StablePaneSpawnContext +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { + if (args.owner) { + const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) + if (attached) { + return attached + } + } + const result = await args.provider.spawn(args.spawnOptions) + args.onFreshSpawn?.(result) + return { result, owner: null } +} + function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { if (pendingByPaneKey.get(paneKey)?.gen !== gen) { return false @@ -1659,8 +1930,13 @@ let rendererGateResetWebContents: WebContents | null = null let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} // Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. const providerSnapshotRequiredPtys = new Set() -// Why: did-start-loading also fires for in-page subframe loads (notebook srcDoc iframes); a dedicated handler filters those via isLoadingMainFrame. -let rendererDidStartLoadingHandler: (() => void) | null = null +type RendererNavigationDetails = { + isMainFrame: boolean + isSameDocument: boolean +} + +// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. +let rendererDidStartNavigationHandler: ((details: RendererNavigationDetails) => void) | null = null // Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. let rebindProviderListeners: (() => void) | null = null @@ -1795,10 +2071,10 @@ function clearRendererLifecycleResetHandlers(): void { if (!rendererLifecycleResetWebContents) { return } - if (rendererDidStartLoadingHandler) { + if (rendererDidStartNavigationHandler) { rendererLifecycleResetWebContents.removeListener( - 'did-start-loading', - rendererDidStartLoadingHandler + 'did-start-navigation', + rendererDidStartNavigationHandler ) } if (rendererLifecycleResetHandler) { @@ -1810,7 +2086,7 @@ function clearRendererLifecycleResetHandlers(): void { } rendererLifecycleResetWebContents = null rendererLifecycleResetHandler = null - rendererDidStartLoadingHandler = null + rendererDidStartNavigationHandler = null } function registerRendererLifecycleResetHandlers(webContents: WebContents): void { @@ -1818,14 +2094,13 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void markRendererPtysHiddenForRendererLifecycleReset() rendererLifecycleResetWebContents = webContents rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset - // Why: did-start-loading also fires for in-page subframe loads (notebook srcDoc iframes); filter via isLoadingMainFrame so a subframe load can't clear pendingData and freeze the alive page. - rendererDidStartLoadingHandler = () => { - if (!webContents.isLoadingMainFrame()) { + rendererDidStartNavigationHandler = (details) => { + if (!details.isMainFrame || details.isSameDocument) { return } markRendererPtysHiddenForRendererLifecycleReset() } - webContents.on('did-start-loading', rendererDidStartLoadingHandler) + webContents.on('did-start-navigation', rendererDidStartNavigationHandler) webContents.on('render-process-gone', rendererLifecycleResetHandler) webContents.on('destroyed', rendererLifecycleResetHandler) } @@ -3811,17 +4086,134 @@ export function registerPtyHandlers( : env } + const adoptStablePane = async (args: { + cols: number + rows: number + cwd?: string + connectionId?: string | null + worktreeId: string + preAllocatedHandle?: string + tabId: string + leafId: string + ownsPaneSpawnReservation?: true + }) => { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined + if (pendingAdoption) { + return await pendingAdoption + } + const activePaneSpawn = + ownerKey && !args.ownsPaneSpawnReservation + ? paneSpawnReservationsByOwnerKey.get(ownerKey) + : undefined + if (activePaneSpawn) { + const result = await activePaneSpawn.promise + const owner = resolveStablePaneOwner( + runtime, + store, + paneKey, + args.worktreeId, + args.connectionId + ) + if ( + !owner || + owner.ptyId !== result.id || + (owner.incarnationId !== undefined && + result.incarnationId !== undefined && + owner.incarnationId !== result.incarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { + result: { + ...result, + isReattach: true, + ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + }, + owner, + materialized: true as const + } + } + const owner = resolveStablePaneOwner( + runtime, + store, + paneKey, + args.worktreeId, + args.connectionId + ) + if (!owner) { + return null + } + const adoption = attachStablePaneOwner({ + runtime, + store, + provider: getProvider(args.connectionId), + spawnOptions: { + cols: args.cols, + rows: args.rows, + cwd: args.cwd + }, + owner, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + }) + if (!ownerKey) { + return await adoption + } + stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) + try { + return await adoption + } finally { + if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { + stablePaneAdoptionsByOwnerKey.delete(ownerKey) + } + } + } + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. runtime?.setPtyController({ + claimStablePaneCreate: (args) => { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} + }, + adoptStablePane, spawn: async (args) => { + const preAdoptedStablePane = args.adoptedStablePane ?? null const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } - await assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (preAdoptedStablePane?.materialized) { + const handle = preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle + if (!handle) { + throw new Error('terminal_pane_owner_unknown') + } + return { + id: preAdoptedStablePane.result.id, + ...(preAdoptedStablePane.result.incarnationId + ? { incarnationId: preAdoptedStablePane.result.incarnationId } + : {}), + ...(typeof preAdoptedStablePane.result.wslDistro === 'string' + ? { wslDistro: preAdoptedStablePane.result.wslDistro } + : {}), + stablePaneOwner: { + handle, + tabId: preAdoptedStablePane.owner.tabId, + leafId: preAdoptedStablePane.owner.leafId + } + } + } + if (!preAdoptedStablePane) { + await assertFolderWorkspacePtyPathUsable(args.worktreeId) + } const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) - const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) + const isClaudeLaunch = + !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -3866,17 +4258,19 @@ export function registerPtyHandlers( cwd, expectedWslDistro ) - const codexResumePreparation = prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: args.env, - workspacePath: cwd - }) + const codexResumePreparation = preAdoptedStablePane + ? null + : prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: args.env, + workspacePath: cwd + }) const codexResumeLaunch = codexResumePreparation ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(args.command) + : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) const codexResumeHome = codexResumeLaunch.codexResumeHome // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. @@ -3928,18 +4322,23 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - let selectedCodexHomePath = !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? codexResumeHome.codexHomePath - : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - if (args.launchAgent === 'codex' && callerRequestedSessionId === undefined) { + let selectedCodexHomePath = + !preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if ( + !preAdoptedStablePane && + args.launchAgent === 'codex' && + callerRequestedSessionId === undefined + ) { const resolution = resolveCodexHomeAfterManagedAuthReadiness({ selectedCodexHomePath, getSettings: () => getSettings?.(), @@ -3980,7 +4379,7 @@ export function registerPtyHandlers( skipCodexHomeEnv, settings: getSettings?.() }) - if (isDaemonHostSpawn && sessionId) { + if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') } @@ -4110,6 +4509,7 @@ export function registerPtyHandlers( : undefined } if ( + !preAdoptedStablePane && args.agentSessionEnsure && (await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false ) { @@ -4117,15 +4517,16 @@ export function registerPtyHandlers( throw new Error('agent_session_claim_unavailable') } if ( + !preAdoptedStablePane && args.agentSessionCreateOperationId && (await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false ) { throw new Error('execution_owner_unavailable') } - if (args.agentSessionEnsure) { + if (!preAdoptedStablePane && args.agentSessionEnsure) { spawnOptions.agentSessionEnsure = args.agentSessionEnsure } - if (args.agentSessionCreateOperationId) { + if (!preAdoptedStablePane && args.agentSessionCreateOperationId) { spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId } if (args.signal) { @@ -4139,21 +4540,54 @@ export function registerPtyHandlers( spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted } - const existingPaneSpawn = materializedPaneKey - ? paneSpawnReservationsByPaneKey.get(materializedPaneKey) + const paneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + spawnIdentityPaneKey + ) + const existingPaneSpawn = paneSpawnReservationKey + ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) : undefined if (existingPaneSpawn) { - return await existingPaneSpawn.promise + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + runtime, + store, + spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + return { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId + ? { incarnationId: concurrentOwner.incarnationId } + : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } + } } const finishTerminalInstall = beginPtySpawnForWorktree( args.worktreeId, cwd, args.connectionId ) - const paneSpawnReservation = materializedPaneKey - ? reservePaneSpawn(materializedPaneKey) + const paneSpawnReservation = paneSpawnReservationKey + ? reservePaneSpawn(paneSpawnReservationKey) : null let result: PtySpawnResult + let stablePaneOwner: StablePaneOwner | null = null let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -4164,7 +4598,19 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { trustedTerminalHandleEnv.add(args.preAllocatedHandle) } - const expectedPtyId = effectiveSessionAppId ?? sessionId + const stablePaneOwnerCandidate = preAdoptedStablePane + ? preAdoptedStablePane.owner + : args.agentSessionEnsure + ? null + : resolveStablePaneOwner( + runtime, + store, + spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? sessionId if (expectedPtyId) { runtime?.beginPtyRegistration?.(expectedPtyId) pendingRegistrationPtyId = expectedPtyId @@ -4172,8 +4618,8 @@ export function registerPtyHandlers( if (isDaemonHostSpawn && expectedPtyId) { preparedProvisionalExecutionContext = runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId, - preserveExisting: !isMintedSessionId + resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, + preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) }) ?? false } const sequenceBeforeProviderSpawn = expectedPtyId @@ -4184,7 +4630,7 @@ export function registerPtyHandlers( throw new Error('client_disconnected') } } - if (args.agentSessionEnsure) { + if (args.agentSessionEnsure && !preAdoptedStablePane) { // Why: daemon-backed claims can outlive this controller; import all // proven owners before deciding that an identity is absent. await reconcileAgentSessionOwnerListings() @@ -4250,10 +4696,37 @@ export function registerPtyHandlers( result.agentSessionEnsure = ensured } else { assertClientStillConnected() - result = await provider.spawn(spawnOptions) + const stablePaneSpawn = preAdoptedStablePane + ? preAdoptedStablePane + : await spawnForStablePane({ + runtime, + store, + provider, + spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + runtime, + store, + spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ), + onFreshSpawn: reportPtySpawnCommitted + }) + result = stablePaneSpawn.result + stablePaneOwner = stablePaneSpawn.owner + if ( + stablePaneOwner && + isMintedSessionId && + effectiveSessionAppId && + effectiveSessionAppId !== result.id + ) { + clearProviderPtyState(effectiveSessionAppId) + } rejectedRegistrationCandidate = result - // Why: daemon/relay returns cross the physical commit boundary before controller admission. - reportPtySpawnCommitted() assertSpawnReplyWasLive(result) } rejectedRegistrationCandidate ??= result @@ -4421,8 +4894,8 @@ export function registerPtyHandlers( } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - deletePtyOwnership(result.id) if (!result.isReattach) { + deletePtyOwnership(result.id) try { await provider.shutdown(result.id, { immediate: true }) } catch (shutdownErr) { @@ -4436,7 +4909,7 @@ export function registerPtyHandlers( } persistSshLease() } - if (args.preAllocatedHandle) { + if (args.preAllocatedHandle && !stablePaneOwner?.handle) { runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) } if (args.worktreeId) { @@ -4464,11 +4937,13 @@ export function registerPtyHandlers( runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) } // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) - if (isClaudeLaunch) { + if (!stablePaneOwner) { + runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) + } + if (isClaudeLaunch && !stablePaneOwner) { markClaudePtySpawned(result.id) } - if (args.telemetry) { + if (args.telemetry && !stablePaneOwner) { const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) @@ -4509,9 +4984,22 @@ export function registerPtyHandlers( const response = { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) + ? { + stablePaneOwner: { + handle: stablePaneOwner.handle ?? args.preAllocatedHandle!, + tabId: stablePaneOwner.tabId, + leafId: stablePaneOwner.leafId + } + } + : {}), ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) } - return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response) + resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, { + ...result, + isReattach: true + }) + return response } catch (err) { if (pendingRegistrationPtyId) { runtime?.cancelPendingPtyRegistration?.( @@ -4523,10 +5011,10 @@ export function registerPtyHandlers( // Why: once the reservation is created, any later throw — spawn // failure, persist failure, or a post-spawn helper such as // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByPaneKey and every future spawn + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn // for this pane awaits a promise that never resolves. reject is a // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err) + rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) throw err } finally { releaseWorktreeSpawn?.() @@ -4929,7 +5417,6 @@ export function registerPtyHandlers( if (startupPromise) { await startupPromise } - await assertFolderWorkspacePtyPathUsable(args.worktreeId) // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. const allowMissingCwdFallback = !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' @@ -4949,411 +5436,504 @@ export function registerPtyHandlers( const startupCwdFallback = didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined spawnTiming.mark('preflight') - const provider = getProvider(args.connectionId) - const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } - const initialShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). - // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. - // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn - const effectiveSessionId = - args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionAppId = - effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, effectiveSessionId) - : undefined - const effectiveSessionRelayId = - effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, effectiveSessionId) - : undefined - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId: effectiveSessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - cwd, - expectedWslDistro - ) - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null - spawnTiming.mark('auth') - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. - // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. - const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - const baseEnvWithAuth = claudeAuth - ? { ...sshSourceEnv, ...claudeAuth.envPatch } - : sshSourceEnv - const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY - const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) - const verifiedPaneKey = - parsedSpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === parsedSpawnPaneKey.tabId && - args.leafId === parsedSpawnPaneKey.leafId - ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) - : null - const verifiedLeafId = - verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null - const metadataLeafId = + const earlyLeafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = + const earlyPaneKey = + typeof args.worktreeId === 'string' && typeof args.tabId === 'string' && isValidTerminalTabId(args.tabId) && args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) + earlyLeafId + ? makePaneKey(args.tabId, earlyLeafId) : null - const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) - const migrationUnsupportedPaneKey = - legacySpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === legacySpawnPaneKey.tabId && - typeof args.leafId === 'string' && - isTerminalLeafId(args.leafId) - ? makePaneKey(args.tabId, args.leafId) - : null - const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey - let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined - const shouldRefreshAgentTeamsEnv = - !args.connectionId && - runtime !== undefined && - stablePaneKey !== null && - shouldRefreshNativeClaudeAgentTeamsEnv({ - command: args.command, - launchConfig: args.launchConfig - }) - let effectiveLaunchConfig = args.launchConfig - const shouldPreAllocateTerminalHandle = - runtime !== undefined && - ((!(provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(provider)) || - shouldRefreshAgentTeamsEnv) - const preAllocatedHandle = shouldPreAllocateTerminalHandle - ? runtime.createPreAllocatedTerminalHandle() - : null - if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { - // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. - const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ - handle: preAllocatedHandle, - baseEnv: baseEnv ?? {} - }) - baseEnv = { - ...baseEnv, - ...prepared.env - } - if (args.launchConfig) { - effectiveLaunchConfig = { - ...args.launchConfig, - agentEnv: { - ...args.launchConfig.agentEnv, - ...prepared.env - } - } - } - } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined - const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv - ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + const earlyReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + earlyPaneKey + ) + const pendingRuntimeCreate = earlyReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) : undefined - if (baseEnv && stablePaneKey) { - baseEnv.ORCA_PANE_KEY = stablePaneKey - if (typeof args.tabId === 'string') { - baseEnv.ORCA_TAB_ID = args.tabId - } else if (!args.connectionId) { - delete baseEnv.ORCA_TAB_ID - } - if (typeof args.worktreeId === 'string') { - baseEnv.ORCA_WORKTREE_ID = args.worktreeId - } else if (!args.connectionId) { - delete baseEnv.ORCA_WORKTREE_ID - } - } else if (baseEnv) { - // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. - delete baseEnv.ORCA_PANE_KEY - delete baseEnv.ORCA_TAB_ID - delete baseEnv.ORCA_WORKTREE_ID - delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN + if (pendingRuntimeCreate) { + await pendingRuntimeCreate.promise } - const validatedPaneKey = stablePaneKey - // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. - const reservationPaneKey = metadataPaneKey ?? validatedPaneKey - const validatedLeafId = verifiedLeafId ?? metadataLeafId - const effectiveShellOverride = terminalRuntimeOptions.shellOverride - const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - const codexSelectionTarget = getCodexSelectionTargetForPty( - effectiveShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: baseEnv, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - const launchCommand = codexResumeLaunch.command - baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch) - // Why: declared after the strip so a local-provider spawn cannot capture the - // pre-strip env — only the daemon branch below re-derives this from baseEnv. - let env: Record | undefined = baseEnv - let selectedCodexHomePath = !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? codexResumeHome.codexHomePath - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - if (args.launchAgent === 'codex' && args.sessionId === undefined) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && - !selectedCodexHomePath - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: getSettings?.() - }) - if (isDaemonHostSpawn) { - if (effectiveSessionId === undefined) { - // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. - throw new Error('Invariant violation: daemon spawn without sessionId') - } - const sessionIdForEnv = effectiveSessionId - // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). - env = { ...baseEnv } - try { - buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: app.isPackaged, - userDataPath: app.getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - shellPath: effectiveShellOverride ?? process.env.COMSPEC, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), - networkProxySettings: getSettings?.(), - deferGitConfigGuardToDaemon: - provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } catch (err) { - // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. - if (isMintedSessionId) { - clearProviderPtyState(sessionIdForEnv) - } - throw err - } - } - spawnTiming.mark('host_env') - const spawnEnv = preAllocatedHandle - ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } - : env - const envToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - let combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, - args.envToDelete ?? [], - agentTeamsEnvToDelete ?? [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], - // Why: the persistent daemon compares its own merged CODEX_HOME pair; - // main cannot safely decide ownership for a process it may not parent. - stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] - ) - if (codexResumeHomeSelected) { - combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) - } - deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) - promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}) - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - if (combinedEnvToDelete) { - spawnOptions.envToDelete = combinedEnvToDelete - } - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - if (reservationPaneKey) { - spawnOptions.paneKey = reservationPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (effectiveSessionId !== undefined) { - spawnOptions.sessionId = effectiveSessionId - } - // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). - if (effectiveShellOverride !== undefined) { - spawnOptions.shellOverride = effectiveShellOverride - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (effectiveSessionId !== undefined) { - // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. - ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { - cols: args.cols, - rows: args.rows - }) - } - if (process.platform === 'win32' && !args.connectionId) { - // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - const existingPaneSpawn = reservationPaneKey - ? paneSpawnReservationsByPaneKey.get(reservationPaneKey) + const existingPaneSpawn = earlyReservationKey + ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) : undefined if (existingPaneSpawn) { - return await existingPaneSpawn.promise + return { ...(await existingPaneSpawn.promise), isReattach: true } } - const finishTerminalInstall = beginPtySpawnForWorktree( - args.worktreeId, - cwd, - args.connectionId - ) - const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null - const initiallyHidden = args.initiallyHidden === true - // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. - const preSpawnHiddenMarkId = - initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined - ? effectiveSessionAppId + const earlyStablePaneOwner = + earlyPaneKey && args.worktreeId + ? resolveStablePaneOwner(runtime, store, earlyPaneKey, args.worktreeId, args.connectionId) : null - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) - } + const earlyWorktreeId = args.worktreeId + let paneSpawnReservationKey = + earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null + let paneSpawnReservation = paneSpawnReservationKey + ? reservePaneSpawn(paneSpawnReservationKey) + : null + let finishTerminalInstall = (): void => {} let result: PtySpawnResult + let stablePaneOwner: StablePaneOwner | null = null let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false let releaseWorktreeSpawn: (() => void) | undefined try { + if (!earlyStablePaneOwner) { + await assertFolderWorkspacePtyPathUsable(args.worktreeId) + } + const provider = getProvider(args.connectionId) + const preAdoptedStablePane = + earlyStablePaneOwner && earlyWorktreeId + ? await adoptStablePane({ + cols: args.cols, + rows: args.rows, + cwd, + connectionId: args.connectionId, + worktreeId: earlyWorktreeId, + tabId: earlyStablePaneOwner.tabId, + leafId: earlyStablePaneOwner.leafId, + ownsPaneSpawnReservation: true + }) + : null + if (earlyStablePaneOwner && !preAdoptedStablePane) { + await assertFolderWorkspacePtyPathUsable(args.worktreeId) + } + const isClaudeLaunch = + !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + const terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } + const initialShellOverride = terminalRuntimeOptions.shellOverride + const isDaemonHostSpawn = + !args.connectionId && + !(provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(provider) + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). + // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. + // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. + const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn + const effectiveSessionId = + args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + const effectiveSessionAppId = + effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, effectiveSessionId) + : undefined + const effectiveSessionRelayId = + effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, effectiveSessionId) + : undefined + const expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd, + sessionId: effectiveSessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + cwd, + expectedWslDistro + ) + const claudeAuth = + isClaudeLaunch && prepareClaudeAuth + ? await prepareClaudeAuth(initialSelectionTarget) + : null + spawnTiming.mark('auth') + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. + // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. + const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + const baseEnvWithAuth = claudeAuth + ? { ...sshSourceEnv, ...claudeAuth.envPatch } + : sshSourceEnv + const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY + const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) + const verifiedPaneKey = + parsedSpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === parsedSpawnPaneKey.tabId && + args.leafId === parsedSpawnPaneKey.leafId + ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) + : null + const verifiedLeafId = + verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null + const metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId + ? makePaneKey(args.tabId, metadataLeafId) + : null + const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) + const migrationUnsupportedPaneKey = + legacySpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === legacySpawnPaneKey.tabId && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null + const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey + let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + const shouldRefreshAgentTeamsEnv = + !preAdoptedStablePane && + !args.connectionId && + runtime !== undefined && + stablePaneKey !== null && + shouldRefreshNativeClaudeAgentTeamsEnv({ + command: args.command, + launchConfig: args.launchConfig + }) + let effectiveLaunchConfig = args.launchConfig + const shouldPreAllocateTerminalHandle = + runtime !== undefined && + ((!(provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(provider)) || + shouldRefreshAgentTeamsEnv) + const preAllocatedHandle = shouldPreAllocateTerminalHandle + ? (preAdoptedStablePane?.owner.handle ?? runtime.createPreAllocatedTerminalHandle()) + : null + if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { + // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. + const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ + handle: preAllocatedHandle, + baseEnv: baseEnv ?? {} + }) + baseEnv = { + ...baseEnv, + ...prepared.env + } + if (args.launchConfig) { + effectiveLaunchConfig = { + ...args.launchConfig, + agentEnv: { + ...args.launchConfig.agentEnv, + ...prepared.env + } + } + } + } + const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined + const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv + ? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + : undefined + if (baseEnv && stablePaneKey) { + baseEnv.ORCA_PANE_KEY = stablePaneKey + if (typeof args.tabId === 'string') { + baseEnv.ORCA_TAB_ID = args.tabId + } else if (!args.connectionId) { + delete baseEnv.ORCA_TAB_ID + } + if (typeof args.worktreeId === 'string') { + baseEnv.ORCA_WORKTREE_ID = args.worktreeId + } else if (!args.connectionId) { + delete baseEnv.ORCA_WORKTREE_ID + } + } else if (baseEnv) { + // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. + delete baseEnv.ORCA_PANE_KEY + delete baseEnv.ORCA_TAB_ID + delete baseEnv.ORCA_WORKTREE_ID + delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN + } + const validatedPaneKey = stablePaneKey + // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. + const reservationPaneKey = metadataPaneKey ?? validatedPaneKey + const validatedLeafId = verifiedLeafId ?? metadataLeafId + const effectiveShellOverride = terminalRuntimeOptions.shellOverride + const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + const codexSelectionTarget = getCodexSelectionTargetForPty( + effectiveShellOverride, + cwd, + expectedWslDistro + ) + const codexResumePreparation = preAdoptedStablePane + ? null + : prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: baseEnv, + workspacePath: cwd + }) + const codexResumeLaunch = codexResumePreparation + ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) + : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = codexResumeLaunch.codexResumeHome + const launchCommand = codexResumeLaunch.command + baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch) + // Why: declared after the strip so a local-provider spawn cannot capture the + // pre-strip env — only the daemon branch below re-derives this from baseEnv. + let env: Record | undefined = baseEnv + let selectedCodexHomePath = + !preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath, + getSettings: () => getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + const codexResumeHomeSelected = Boolean( + codexResumeHome && + codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + const skipCodexHomeEnv = + isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && + !selectedCodexHomePath + const stripInheritedOrcaCodexHome = + isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }) + if (isDaemonHostSpawn && !preAdoptedStablePane) { + if (effectiveSessionId === undefined) { + // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. + throw new Error('Invariant violation: daemon spawn without sessionId') + } + const sessionIdForEnv = effectiveSessionId + // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. + if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). + env = { ...baseEnv } + try { + buildPtyHostEnv(sessionIdForEnv, env, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + shellPath: effectiveShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), + wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), + networkProxySettings: getSettings?.(), + deferGitConfigGuardToDaemon: + provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + env, + runtime?.getOrchestrationCompatibilityHostId?.(), + codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null + ) + promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) + } catch (err) { + // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. + if (isMintedSessionId) { + clearProviderPtyState(sessionIdForEnv) + } + throw err + } + } + spawnTiming.mark('host_env') + const spawnEnv = preAllocatedHandle + ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } + : env + const envToDelete = claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + let combinedEnvToDelete = mergePtyEnvDeletions( + envToDelete, + args.envToDelete ?? [], + agentTeamsEnvToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], + // Why: the persistent daemon compares its own merged CODEX_HOME pair; + // main cannot safely decide ownership for a process it may not parent. + stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) + if (codexResumeHomeSelected) { + combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) + } + deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) + promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) + const spawnOptions: PtySpawnOptions = { + cols: args.cols, + rows: args.rows, + cwd, + env: spawnEnv, + ...(isMintedSessionId ? { isNewSession: true } : {}) + } + if (!args.connectionId && !isDaemonHostSpawn) { + spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } + } + if (combinedEnvToDelete) { + spawnOptions.envToDelete = combinedEnvToDelete + } + if (launchCommand !== undefined) { + spawnOptions.command = launchCommand + } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + spawnOptions.worktreeId = args.worktreeId + } + if (reservationPaneKey) { + spawnOptions.paneKey = reservationPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + spawnOptions.tabId = args.tabId + } + if (effectiveSessionId !== undefined) { + spawnOptions.sessionId = effectiveSessionId + } + // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). + if (effectiveShellOverride !== undefined) { + spawnOptions.shellOverride = effectiveShellOverride + } + const hadSessionSizeBeforeAttach = + effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false + const sessionSizeBeforeAttach = + effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined + if (effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. + ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { + cols: args.cols, + rows: args.rows + }) + } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. + spawnOptions.terminalWindowsWslDistro = expectedWslDistro + spawnOptions.terminalWindowsPowerShellImplementation = getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + if (startupTerminalColorQueryReplyColors) { + spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + reservationPaneKey + ) + if ( + paneSpawnReservationKey && + resolvedPaneSpawnReservationKey !== paneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!paneSpawnReservationKey) { + paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const pendingRuntimeCreateAfterPreflight = paneSpawnReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(paneSpawnReservationKey) + : undefined + if (pendingRuntimeCreateAfterPreflight) { + await pendingRuntimeCreateAfterPreflight.promise + } + const existingPaneSpawnAfterPreflight = paneSpawnReservationKey + ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) + : undefined + if (existingPaneSpawnAfterPreflight) { + return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true } + } + paneSpawnReservation = paneSpawnReservationKey + ? reservePaneSpawn(paneSpawnReservationKey) + : null + } + finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, cwd, args.connectionId) + const initiallyHidden = args.initiallyHidden === true + // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) + } releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) try { if (preAllocatedHandle) { trustedTerminalHandleEnv.add(preAllocatedHandle) } spawnTiming.mark('options') - const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + const stablePaneOwnerCandidate = resolveStablePaneOwner( + runtime, + store, + reservationPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? effectiveSessionId if (expectedPtyId) { runtime?.beginPtyRegistration?.(expectedPtyId) pendingRegistrationPtyId = expectedPtyId @@ -5361,14 +5941,42 @@ export function registerPtyHandlers( if (isDaemonHostSpawn && expectedPtyId) { preparedProvisionalExecutionContext = runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId, - preserveExisting: !isMintedSessionId + resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, + preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) }) ?? false } const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 - result = await provider.spawn(spawnOptions) + const stablePaneSpawn = preAdoptedStablePane + ? preAdoptedStablePane + : await spawnForStablePane({ + runtime, + store, + provider, + spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + runtime, + store, + reservationPaneKey, + args.worktreeId, + args.connectionId + ) + }) + result = stablePaneSpawn.result + stablePaneOwner = stablePaneSpawn.owner + if ( + stablePaneOwner && + isMintedSessionId && + effectiveSessionAppId && + effectiveSessionAppId !== result.id + ) { + clearProviderPtyState(effectiveSessionAppId) + } rejectedRegistrationCandidate = result if (pendingRegistrationPtyId !== result.id) { if (pendingRegistrationPtyId) { @@ -5518,10 +6126,13 @@ export function registerPtyHandlers( lastAttachedAt: Date.now() }) } - if (preAllocatedHandle) { + if (preAllocatedHandle && !stablePaneOwner?.handle) { runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) } ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { + ptySizes.delete(effectiveSessionAppId) + } // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). if ( store && @@ -5653,11 +6264,13 @@ export function registerPtyHandlers( pendingRegistrationPtyId = null } // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. - runtime?.noteTerminalSpawnCommand?.( - result.id, - typeof launchCommand === 'string' ? launchCommand : null - ) - if (isClaudeLaunch) { + if (!stablePaneOwner) { + runtime?.noteTerminalSpawnCommand?.( + result.id, + typeof launchCommand === 'string' ? launchCommand : null + ) + } + if (isClaudeLaunch && !stablePaneOwner) { markClaudePtySpawned(result.id) } // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. @@ -5706,7 +6319,7 @@ export function registerPtyHandlers( }) } // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). - if (args.telemetry) { + if (args.telemetry && !stablePaneOwner) { const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) @@ -5734,7 +6347,7 @@ export function registerPtyHandlers( } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) - return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) + return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { if (pendingRegistrationPtyId) { runtime?.cancelPendingPtyRegistration?.( @@ -5746,10 +6359,10 @@ export function registerPtyHandlers( // Why: once the reservation is created, any later throw — // spawn failure, persist failure, or a post-spawn helper such as // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByPaneKey and every future spawn + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn // for this pane awaits a promise that never resolves. reject is a // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err) + rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) throw err } finally { releaseWorktreeSpawn?.() diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 34f1611d5..b7dcb4399 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index b52f03af0..124188770 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 { - 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() diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index f305f1e85..665d8e15c 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4eb1a6e3b..fab9c65dd 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d1abd0c8f..26db5a196 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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> | 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>> + 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() } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1b62060a6..3044d90c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0f4e730e8..5edb8ac15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5167,6 +5167,7 @@ export function connectPanePty( const trackedPromise: Promise = 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 diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index f56949a2c..b0dd95941 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index ea7d9d043..c9973b823 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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 + 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 + 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 @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 6b777cbb2..d612b13cc 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -822,6 +822,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return spawnResult } + if (spawnResult.isReattach && !admittedSessionId) { + storedCallbacks.onReattachDetermined?.() + } ptyId = spawnResult.id connected = true diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 1f9aef116..eedd195c7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts index 40a8a8b6f..7baf935ae 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts @@ -19,14 +19,27 @@ const retryByPane = new WeakMap() function scheduleRetryTick(run: () => void): RetrySchedule { if (typeof requestAnimationFrame === 'function') { let cancelled = false + let settled = false let timer: ReturnType | 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 diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index a0864bcbb..b9d078afc 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -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)', () => { diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index f10216dde..714d962a1 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -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 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index ecc9fcef1..f65912cc4 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -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 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index cbb8fa223..c8291e35f 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts new file mode 100644 index 000000000..ea6ffd187 --- /dev/null +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -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(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 { + const listed = await client.call('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 { + 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 { + 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 { + 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 { + await expect + .poll(async () => (await readWorktreeTerminals(client, worktreeId)).map(liveTerminalIdentity), { + timeout: 15_000 + }) + .toEqual(originals.map(liveTerminalIdentity)) +} + +async function assertLaunchLedgersUnchanged(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[0], + ptyId: string, + marker: string +): Promise { + 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('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('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('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('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) +})