From 7adda25b0a72598ea92f7955045adaa158da54cd Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 19 Jul 2026 19:38:44 -0700
Subject: [PATCH 01/35] fix(daemon): retire empty current-generation daemons
(#9277)
* fix(daemon): retire empty current-generation daemons
Co-authored-by: Orca
* fix(daemon): retire empty daemons on disconnect
Co-authored-by: Orca
* test(daemon): authenticate Windows lifecycle harness
Co-authored-by: Orca
* test(daemon): assert remaining shutdown budget
Co-authored-by: Orca
---------
Co-authored-by: Orca
---
.../windows-daemon-workspace-close-repro.mjs | 122 ++-
.../2026-07-18-daemon-lifecycle-retirement.md | 279 ++++++
src/main/daemon/client.test.ts | 100 +-
src/main/daemon/client.ts | 188 +++-
src/main/daemon/daemon-entry.test.ts | 43 +
src/main/daemon/daemon-entry.ts | 39 +-
...n-foreground-confirmation-protocol.test.ts | 4 +-
src/main/daemon/daemon-hello-protocol.ts | 20 +
src/main/daemon/daemon-idle-shutdown.test.ts | 728 +++++++++++++++
src/main/daemon/daemon-init.test.ts | 761 ++++++++++++++-
src/main/daemon/daemon-init.ts | 878 ++++++++++++------
src/main/daemon/daemon-main.ts | 17 +-
src/main/daemon/daemon-pty-adapter.ts | 90 +-
.../daemon-self-retirement-respawn.test.ts | 228 +++++
src/main/daemon/daemon-server.test.ts | 95 +-
src/main/daemon/daemon-server.ts | 492 ++++++++--
src/main/daemon/daemon-spawner.test.ts | 67 +-
src/main/daemon/daemon-spawner.ts | 98 +-
src/main/daemon/production-launcher.test.ts | 215 ++++-
src/main/daemon/production-launcher.ts | 188 +++-
src/main/daemon/types.ts | 29 +-
tests/e2e/daemon-lifecycle-retirement.spec.ts | 241 +++++
tests/e2e/fixtures/daemon-lifecycle-entry.ts | 122 +++
23 files changed, 4492 insertions(+), 552 deletions(-)
create mode 100644 docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md
create mode 100644 src/main/daemon/daemon-hello-protocol.ts
create mode 100644 src/main/daemon/daemon-idle-shutdown.test.ts
create mode 100644 src/main/daemon/daemon-self-retirement-respawn.test.ts
create mode 100644 tests/e2e/daemon-lifecycle-retirement.spec.ts
create mode 100644 tests/e2e/fixtures/daemon-lifecycle-entry.ts
diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs
index be920ca7192..974e865722d 100644
--- a/config/scripts/windows-daemon-workspace-close-repro.mjs
+++ b/config/scripts/windows-daemon-workspace-close-repro.mjs
@@ -30,19 +30,99 @@ function readProtocolVersion() {
return Number(match[1])
}
+function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) {
+ const socket = connect(socketPath)
+ let buffer = ''
+
+ return new Promise((resolveStream, rejectStream) => {
+ let handshakeComplete = false
+ const rejectHandshake = (error) => {
+ if (handshakeComplete) {
+ onFailure(error)
+ return
+ }
+ handshakeComplete = true
+ clearTimeout(timer)
+ socket.removeListener('data', onData)
+ socket.removeListener('error', onError)
+ socket.removeListener('close', onClose)
+ socket.destroy()
+ rejectStream(error)
+ }
+ const onError = (error) => rejectHandshake(error)
+ const onClose = () => rejectHandshake(new Error('Daemon stream socket closed'))
+ const onData = (chunk) => {
+ buffer += chunk.toString('utf8')
+ const newline = buffer.indexOf('\n')
+ if (newline === -1) {
+ return
+ }
+ const message = JSON.parse(buffer.slice(0, newline))
+ if (message.type !== 'hello') {
+ return
+ }
+ if (!message.ok) {
+ rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello'))
+ return
+ }
+ handshakeComplete = true
+ clearTimeout(timer)
+ socket.removeListener('data', onData)
+ // Why: drain terminal events even though this lifecycle repro only asserts through RPC.
+ socket.on('data', () => {})
+ resolveStream(socket)
+ }
+ const timer = setTimeout(
+ () => rejectHandshake(new Error('Daemon stream hello timed out')),
+ requestTimeoutMs
+ )
+ socket.on('error', onError)
+ socket.on('close', onClose)
+ socket.on('data', onData)
+ socket.once('connect', () => {
+ socket.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: protocolVersion,
+ token,
+ clientId,
+ role: 'stream'
+ })}\n`
+ )
+ })
+ })
+}
+
function createRpcClient(socketPath, tokenPath) {
const socket = connect(socketPath)
+ const clientId = randomUUID()
+ const protocolVersion = readProtocolVersion()
+ const token = readFileSync(tokenPath, 'utf8').trim()
const pending = new Map()
let buffer = ''
let requestId = 0
+ let streamSocket
+ let connectionError
let helloResolve
let helloReject
+ let helloTimer
const hello = new Promise((resolveHello, rejectHello) => {
- helloResolve = resolveHello
- helloReject = rejectHello
+ helloResolve = () => {
+ clearTimeout(helloTimer)
+ resolveHello()
+ }
+ helloReject = (error) => {
+ clearTimeout(helloTimer)
+ rejectHello(error)
+ }
})
+ helloTimer = setTimeout(() => {
+ helloReject(new Error('Daemon control hello timed out'))
+ socket.destroy()
+ }, requestTimeoutMs)
const rejectPending = (error) => {
+ connectionError ??= error
helloReject(error)
for (const { reject, timer } of pending.values()) {
clearTimeout(timer)
@@ -82,25 +162,40 @@ function createRpcClient(socketPath, tokenPath) {
}
})
- const connected = new Promise((resolveConnected, rejectConnected) => {
+ const socketConnected = new Promise((resolveConnected, rejectConnected) => {
socket.once('connect', resolveConnected)
socket.once('error', rejectConnected)
- }).then(() => {
- socket.write(
- `${JSON.stringify({
- type: 'hello',
- version: readProtocolVersion(),
- token: readFileSync(tokenPath, 'utf8').trim(),
- clientId: randomUUID(),
- role: 'control'
- })}\n`
+ })
+ const connected = Promise.all([
+ socketConnected.then(() => {
+ socket.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: protocolVersion,
+ token,
+ clientId,
+ role: 'control'
+ })}\n`
+ )
+ }),
+ hello
+ ]).then(async () => {
+ // Why: v24 only admits terminals for the same complete control+stream pair as production.
+ streamSocket = await createStreamSocket(
+ socketPath,
+ token,
+ protocolVersion,
+ clientId,
+ rejectPending
)
- return hello
})
return {
async request(type, payload) {
await connected
+ if (connectionError) {
+ throw connectionError
+ }
const id = `repro-${++requestId}`
return new Promise((resolveRequest, rejectRequest) => {
const timer = setTimeout(() => {
@@ -112,6 +207,7 @@ function createRpcClient(socketPath, tokenPath) {
})
},
close() {
+ streamSocket?.destroy()
socket.destroy()
}
}
diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md
new file mode 100644
index 00000000000..104f6409929
--- /dev/null
+++ b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md
@@ -0,0 +1,279 @@
+# Daemon lifecycle retirement for issue #9138
+
+## Status
+
+Implemented and locally validated for PR #9277.
+
+The earlier full ownership/audit prototype is preserved at:
+
+- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot`
+- commit: `7c915909bd26670b8af36aa683cff85077395dd1`
+- GitHub:
+
+That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup
+audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future
+legacy enforcement. None of those systems ship in this PR.
+
+Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was
+in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation
+alongside v22 and older versions.
+
+## Inputs and scope decision
+
+Issue:
+
+Reviewed design comment by AmethystLiang:
+
+
+The reviewed comment correctly identifies two different problems:
+
+1. current-generation daemons have no daemon-owned empty lifecycle;
+2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act.
+
+This PR solves only the first problem. The second problem is much larger, adds steady-state
+persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement.
+Keeping it out makes the user-visible fix small enough to review and benchmark independently.
+
+The narrow implementation retains these important rules from AmethystLiang's design:
+
+- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it;
+- live sessions always win over cleanup;
+- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence;
+- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation;
+- the daemon, not app startup, makes the atomic empty decision;
+- pre-v24 daemons stay reattachable and are not automatically shut down;
+- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged.
+
+The narrow implementation changes one policy from the original comment: it does not use a blanket
+30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle
+event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime
+inactivity heuristics and periodic ownership work.
+
+## User-visible behavior
+
+```text
+Before
+
+app disconnects ──> daemon stays forever
+ ├── live sessions stay (wanted)
+ └── zero sessions also stay (leak)
+
+After
+
+clean app detach ──> daemon atomically checks itself
+ ├── any live session/work/client ──> stay alive
+ └── exactly empty ──> exit immediately
+
+unexpected drop ──> daemon atomically checks itself
+ ├── live session/work/connection ──> stay alive
+ └── exactly empty ──> exit immediately
+
+v23 and older ──> existing reattach behavior; no automatic retirement
+```
+
+An end user with live terminals should notice no change. An end user who quits with no daemon-backed
+terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket,
+live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later
+app restart launches a fresh daemon instead of reusing an empty process.
+
+## Protocol and lifecycle design
+
+### Endpoint identity
+
+Protocol v24 hello responses include:
+
+```ts
+type DaemonEndpointIdentity = {
+ pid: number
+ startedAtMs: number
+ launchNonce: string
+}
+```
+
+The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes
+the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client
+sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps
+the previous identity-free handshake.
+
+PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a
+write failure terminates the new child and fails launch instead of leaving an untracked daemon.
+
+### Clean detach
+
+At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a
+v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared
+250 ms budget. v23 and older adapters skip it.
+
+Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is
+opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still
+receive clean retirement on quit. If startup fallback has already won, the late daemon is not
+installed; it instead receives the same bounded retirement attempt, which an adopted live session
+will reject.
+
+The daemon accepts retirement only when, in one event-loop turn:
+
+- the requesting authenticated client has both control and stream sockets;
+- it is the only authenticated client;
+- every accepted transport belongs to that client;
+- no `createOrAttach` operation is in flight;
+- the terminal host has zero sessions.
+
+When all conditions hold, the daemon synchronously closes the listening server before replying. That
+is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then
+runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to
+the same event-driven empty check when the authenticated sockets close.
+
+### Initial adoption watchdog
+
+A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated
+client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the
+normal launch handoff before the parent could connect; without a bound, a parent crash during that
+handoff would orphan the new daemon forever.
+
+A complete pair permanently cancels this watchdog, and terminal admission requires that complete
+pair. Raw and partial transports pause it without extending its original deadline. It is never
+rearmed after adoption and is not a terminal inactivity or crash-reconnect timer.
+
+### Unexpected disconnect
+
+When the last client that completed both authenticated sockets loses its control connection, the
+daemon records an event-driven retirement request with no wall-clock grace.
+
+- A complete authenticated reconnect cancels the request if existing work or a transport kept the
+ daemon alive long enough to reconnect.
+- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request
+ before admission can begin.
+- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but
+ cannot erase evidence that the last fully connected app left.
+- Replacing a client ID first records the old full connection's loss; completing the replacement
+ stream cancels that evidence, while an incomplete replacement only blocks retirement.
+- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately
+ rechecks every guard and retires only if it is then exactly empty.
+
+The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the
+token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is
+not broadened into destructive or respawn authority.
+
+### Artifact cleanup
+
+The daemon removes only artifacts it can claim as its own:
+
+- token contents must match the daemon's in-memory token;
+- PID and launch nonce must match the daemon process and launch nonce;
+- cleanup first renames the canonical entry to a unique claim, validates that claim, and never
+ overwrites or unlinks a replacement installed at the canonical path.
+
+Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24
+PID/socket artifacts. Legacy cleanup behavior is unchanged.
+
+## Performance design
+
+There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or
+steady-state persistence write in this PR.
+
+The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one
+two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its
+connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection
+attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect
+bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one
+unref'ed startup watchdog, which is canceled permanently on adoption.
+
+Validation compares current main and the branch for:
+
+- daemon connect plus two-socket hello latency;
+- repeated `listSessions` RPC latency;
+- terminal echo/stream throughput through a real socket daemon;
+- clean empty-detach latency;
+- event-loop delay under repeated RPC/stream work;
+- idle CPU/RSS and timer count where observable.
+
+The acceptance target is no statistically meaningful terminal throughput regression and no new
+steady-state disk writes. Results are recorded in the PR body.
+
+### Local performance regression screen
+
+The final local host was not quiet enough for publication-grade absolute numbers: load averages were
+17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired
+same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24
+branch samples; medians across sample medians were:
+
+| Measure | `main` v23 | branch v24 |
+| -------------------- | ---------: | ---------: |
+| two-socket connect | 1.38 ms | 1.32 ms |
+| `listSessions` RPC | 0.0366 ms | 0.0374 ms |
+| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s |
+
+All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and
+event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression
+screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work
+runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY
+byte and no steady-state persistence or polling.
+
+## Verification and validation
+
+### Focused unit and integration tests
+
+- v24 requires valid matching endpoint identity on both sockets.
+- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols.
+- production launch passes PID path and nonce and writes the exact readiness identity.
+- incomplete readiness identity or failed exclusive PID publication kills and rejects the child.
+- clean empty detach exits immediately.
+- a never-used current adapter connects and retires cleanly on quit.
+- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old
+ deadline.
+- a live session, another client, raw transport, or in-flight admission rejects clean retirement.
+- a control-only overlapping client blocks but cannot erase the last full-client retirement request.
+- a same-client-ID control replacement cannot erase the prior full connection's retirement request.
+- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed
+ request.
+- startup fail-open performs bounded empty retirement without installing a late provider.
+- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later.
+- the synchronous listener fence rejects post-fence connections as retryable.
+- an unexpected empty disconnect retires immediately without a runtime inactivity timer.
+- a real reconnect cancels pending retirement while live work keeps the daemon available.
+- raw and health probes block but cannot erase pending retirement.
+- final session exit triggers an immediate guarded retirement check.
+- token/PID cleanup preserves malformed, stale, and replacement artifacts.
+- authenticated token disappearance performs one coalesced respawn; initial token absence does not.
+
+### Process/E2E tests
+
+- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts;
+- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts
+ exit;
+- prove a live session rejects retirement and remains reattachable;
+- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable;
+- never target a production runtime directory or signal a process not created by the fixture.
+
+### Repository validation
+
+- Node typecheck;
+- oxlint and repository max-lines policy;
+- focused daemon, adapter, launcher, restart, and legacy-routing suites;
+- full daemon test suite;
+- desktop and web production builds;
+- `git diff --check` and review of every changed file against `origin/main`;
+- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`;
+- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication.
+
+Final local results on macOS arm64 after the event-driven policy revision:
+
+- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped;
+- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned
+ artifacts retired immediately after its final authenticated client disconnected;
+- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed;
+- full desktop, web, and native production build passed with existing build warnings;
+- three independent post-revision review tracks covering architecture/state machines,
+ ownership/adoption, and process lifecycle ended clean after actionable findings were fixed.
+
+The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed
+successfully, and repository CI remains responsible for the supported Node/platform matrix.
+
+## Rollout and rollback
+
+This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves
+v24 as another legacy generation and does not give an older app authority to shut it down.
+
+If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated
+ownership/audit prototype remains recoverable from the archived branch and commit above; it should
+return only as a separately reviewed, benchmarked follow-up.
diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts
index 25aaebe164f..a3d6d9f610d 100644
--- a/src/main/daemon/client.test.ts
+++ b/src/main/daemon/client.test.ts
@@ -68,6 +68,12 @@ describe('DaemonClient', () => {
onStreamHello?: (msg: HelloMessage) => void
rejectVersion?: boolean
suppressHelloResponse?: boolean
+ omitHelloIdentity?: boolean
+ helloIdentity?: (role: 'control' | 'stream') => {
+ pid: number
+ startedAtMs: number
+ launchNonce: string
+ }
}): Promise {
return new Promise((resolve) => {
server = createServer((socket) => {
@@ -103,7 +109,19 @@ describe('DaemonClient', () => {
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' }))
return
}
- socket.write(encodeNdjson({ type: 'hello', ok: true }))
+ socket.write(
+ encodeNdjson({
+ type: 'hello',
+ ok: true,
+ ...(!opts?.omitHelloIdentity
+ ? {
+ daemonIdentity: opts?.helloIdentity
+ ? opts.helloIdentity(hello.role)
+ : { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' }
+ }
+ : {})
+ })
+ )
if (hello.role === 'stream') {
opts?.onStreamHello?.(hello)
}
@@ -136,6 +154,48 @@ describe('DaemonClient', () => {
await waitFor(() => hellos.length > 0)
})
+ it('captures one matching endpoint identity from both authenticated sockets', async () => {
+ const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' }
+ await startMockDaemon({ helloIdentity: () => identity })
+
+ client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+
+ expect(client.getDaemonIdentity()).toEqual(identity)
+ })
+
+ it('rejects a v24 daemon that omits endpoint identity', async () => {
+ await startMockDaemon({ omitHelloIdentity: true })
+ client = new DaemonClient({ socketPath, tokenPath })
+
+ await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity')
+ })
+
+ it('allows a legacy v23 daemon to omit endpoint identity', async () => {
+ await startMockDaemon({ omitHelloIdentity: true })
+ client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 23 })
+
+ await expect(client.ensureConnected()).resolves.toBeUndefined()
+ expect(client.getDaemonIdentity()).toBeNull()
+ })
+
+ it('rejects when control and stream sockets report different daemon identities', async () => {
+ await startMockDaemon({
+ helloIdentity: (role) => ({
+ pid: role === 'control' ? 123 : 124,
+ startedAtMs: 456,
+ launchNonce: 'launch-a'
+ })
+ })
+
+ client = new DaemonClient({ socketPath, tokenPath })
+
+ await expect(client.ensureConnected()).rejects.toThrow(
+ 'Daemon identity changed during connection'
+ )
+ expect(client.getDaemonIdentity()).toBeNull()
+ })
+
it('removes socket startup listeners after connecting', async () => {
await startMockDaemon()
@@ -189,6 +249,35 @@ describe('DaemonClient', () => {
}
})
+ it('bounds a waiter on an existing connection attempt and prevents socket resurrection', async () => {
+ let resolveHello: () => void = () => {}
+ const helloReceived = new Promise((resolve) => {
+ resolveHello = resolve
+ })
+ await startMockDaemon({
+ suppressHelloResponse: true,
+ onHello: resolveHello
+ })
+ client = new DaemonClient({ socketPath, tokenPath })
+ const ownerAttempt = client.ensureConnected().catch((error: Error) => error)
+ await helloReceived
+
+ await expect(client.ensureConnectedWithin(25)).rejects.toThrow(
+ 'Connection attempt wait timed out'
+ )
+ client.disconnect()
+ await expect(ownerAttempt).resolves.toBeInstanceOf(Error)
+ await new Promise((resolve) => setTimeout(resolve, 25))
+
+ const disconnected = client as unknown as {
+ controlSocket: Socket | null
+ streamSocket: Socket | null
+ }
+ expect(client.isConnected()).toBe(false)
+ expect(disconnected.controlSocket).toBeNull()
+ expect(disconnected.streamSocket).toBeNull()
+ })
+
it('removes hello startup listeners after timeout', async () => {
vi.useFakeTimers()
@@ -200,11 +289,16 @@ describe('DaemonClient', () => {
socket.destroy = destroy as unknown as Socket['destroy']
const sendHello = (
client as unknown as {
- sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise
+ sendHello(
+ socket: Socket,
+ token: string,
+ role: 'control' | 'stream',
+ timeoutMs: number
+ ): Promise
}
).sendHello.bind(client)
- const promise = sendHello(socket, 'test-token-123', 'control')
+ const promise = sendHello(socket, 'test-token-123', 'control', 5000)
const rejection = expect(promise).rejects.toThrow('Hello response timed out')
await vi.advanceTimersByTimeAsync(5000)
diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts
index 8b6c1b29d90..d0dd2453471 100644
--- a/src/main/daemon/client.ts
+++ b/src/main/daemon/client.ts
@@ -4,11 +4,23 @@ import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
-import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
-import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types'
+import {
+ CLEAN_DISCONNECT_PROTOCOL_VERSION,
+ PROTOCOL_VERSION,
+ NOTIFY_PREFIX,
+ DaemonProtocolError
+} from './types'
+import type {
+ DaemonEndpointIdentity,
+ HelloMessage,
+ HelloResponse,
+ RpcResponse,
+ DaemonEvent
+} from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
const CONNECT_TIMEOUT_MS = 5000
+const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
const REQUEST_TIMEOUT_MS = 30000
export type DaemonClientOptions = {
@@ -42,6 +54,9 @@ export class DaemonClient {
// all call ensureConnected(). Without a lock, each starts a separate
// connection attempt, overwriting sockets and triggering "Connection lost".
private connectingPromise: Promise | null = null
+ private connectionAttemptGeneration = 0
+ private daemonIdentity: DaemonEndpointIdentity | null = null
+ private observedAuthenticatedDisconnect = false
private pendingRequests = new Map()
private eventListeners: ((event: unknown) => void)[] = []
@@ -59,15 +74,38 @@ export class DaemonClient {
return this.connected
}
+ getDaemonIdentity(): DaemonEndpointIdentity | null {
+ return this.daemonIdentity ? { ...this.daemonIdentity } : null
+ }
+
+ hasObservedAuthenticatedDisconnect(): boolean {
+ return this.observedAuthenticatedDisconnect
+ }
+
async ensureConnected(): Promise {
+ return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false)
+ }
+
+ async ensureConnectedWithin(timeoutMs: number): Promise {
+ return this.ensureConnectedWithTimeout(timeoutMs, true)
+ }
+
+ private async ensureConnectedWithTimeout(
+ timeoutMs: number,
+ sharedBudget: boolean
+ ): Promise {
if (this.connected) {
return
}
if (this.connectingPromise) {
- return this.connectingPromise
+ // Why: a normal connection may legitimately consume one timeout for each
+ // socket and hello; bounded teardown calls instead keep their one shared budget.
+ const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS
+ return this.waitForConnectionAttempt(this.connectingPromise, waiterTimeoutMs)
}
- this.connectingPromise = this.doConnect()
+ const attemptGeneration = this.connectionAttemptGeneration
+ this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget)
try {
await this.connectingPromise
} finally {
@@ -75,8 +113,15 @@ export class DaemonClient {
}
}
- private async doConnect(): Promise {
+ private async doConnect(
+ timeoutMs: number,
+ attemptGeneration: number,
+ sharedBudget: boolean
+ ): Promise {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
+ const deadlineMs = Date.now() + timeoutMs
+ const remainingMs = (): number =>
+ sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs
const pendingListenerCleanups: (() => void)[] = []
const cleanupPendingListeners = (): void => {
for (const cleanup of pendingListenerCleanups.splice(0)) {
@@ -86,15 +131,32 @@ export class DaemonClient {
try {
// Sequential: control first, then stream
- this.controlSocket = await this.connectSocket()
- await this.sendHello(this.controlSocket, token, 'control')
+ const pendingControlSocket = await this.connectSocket(remainingMs())
+ this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket)
+ this.controlSocket = pendingControlSocket
+ const controlIdentity = await this.sendHello(
+ this.controlSocket,
+ token,
+ 'control',
+ remainingMs()
+ )
+ this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket)
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket))
- this.streamSocket = await this.connectSocket()
- await this.sendHello(this.streamSocket, token, 'stream')
+ const pendingStreamSocket = await this.connectSocket(remainingMs())
+ this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket)
+ this.streamSocket = pendingStreamSocket
+ const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs())
+ this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket)
+ if (!sameDaemonIdentity(controlIdentity, streamIdentity)) {
+ throw new DaemonProtocolError('Daemon identity changed during connection')
+ }
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket))
+ this.assertConnectionAttemptCurrent(attemptGeneration)
this.connected = true
+ this.observedAuthenticatedDisconnect = false
+ this.daemonIdentity = controlIdentity
this.disconnectArmed = true
this.connectionGeneration++
@@ -120,12 +182,17 @@ export class DaemonClient {
this.controlSocket = null
this.streamSocket = null
this.connected = false
+ this.daemonIdentity = null
this.disconnectArmed = false
throw error
}
}
- async request(type: string, payload: unknown): Promise {
+ async request(
+ type: string,
+ payload: unknown,
+ timeoutMs = REQUEST_TIMEOUT_MS
+ ): Promise {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
}
@@ -136,8 +203,8 @@ export class DaemonClient {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
- reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`))
- }, REQUEST_TIMEOUT_MS)
+ reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`))
+ }, timeoutMs)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
@@ -180,7 +247,9 @@ export class DaemonClient {
}
disconnect(): void {
+ this.connectionAttemptGeneration++
this.connected = false
+ this.daemonIdentity = null
this.disconnectArmed = false
this.cleanupActiveSocketListeners()
@@ -196,7 +265,7 @@ export class DaemonClient {
this.streamSocket = null
}
- private connectSocket(): Promise {
+ private connectSocket(timeoutMs: number): Promise {
return new Promise((resolve, reject) => {
const socket = connect(this.socketPath)
const cleanup = (): void => {
@@ -216,14 +285,45 @@ export class DaemonClient {
cleanup()
socket.destroy()
reject(new DaemonProtocolError('Connection timed out'))
- }, CONNECT_TIMEOUT_MS)
+ }, timeoutMs)
socket.on('connect', onConnect)
socket.on('error', onError)
})
}
- private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise {
+ private waitForConnectionAttempt(attempt: Promise, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ reject(new DaemonProtocolError('Connection attempt wait timed out'))
+ }, timeoutMs)
+ attempt.then(
+ () => {
+ clearTimeout(timer)
+ resolve()
+ },
+ (error) => {
+ clearTimeout(timer)
+ reject(error)
+ }
+ )
+ })
+ }
+
+ private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void {
+ if (attemptGeneration === this.connectionAttemptGeneration) {
+ return
+ }
+ socket?.destroy()
+ throw new DaemonProtocolError('Disconnected')
+ }
+
+ private sendHello(
+ socket: Socket,
+ token: string,
+ role: 'control' | 'stream',
+ timeoutMs: number
+ ): Promise {
return new Promise((resolve, reject) => {
const hello: HelloMessage = {
type: 'hello',
@@ -245,7 +345,7 @@ export class DaemonClient {
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
}
- const finish = (error?: Error): void => {
+ const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => {
if (settled) {
return
}
@@ -255,7 +355,7 @@ export class DaemonClient {
reject(error)
return
}
- resolve()
+ resolve(identity)
}
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
// Decoding each Buffer independently would permanently inject U+FFFD.
@@ -271,7 +371,15 @@ export class DaemonClient {
try {
const response = JSON.parse(line) as HelloResponse
if (response.ok) {
- finish()
+ const identity = parseDaemonEndpointIdentity(response.daemonIdentity)
+ if (
+ (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION && identity === null) ||
+ (response.daemonIdentity !== undefined && identity === null)
+ ) {
+ finish(new DaemonProtocolError('Invalid daemon identity'))
+ return
+ }
+ finish(undefined, identity)
} else {
finish(
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
@@ -290,7 +398,7 @@ export class DaemonClient {
// without a handshake timeout, startup waits forever on ensureConnected().
finish(new DaemonProtocolError('Hello response timed out'))
socket.destroy()
- }, CONNECT_TIMEOUT_MS)
+ }, timeoutMs)
socket.on('data', onData)
socket.on('error', onError)
socket.on('close', onClose)
@@ -352,7 +460,12 @@ export class DaemonClient {
return
}
this.disconnectArmed = false
+ this.connectionAttemptGeneration++
+ if (this.daemonIdentity) {
+ this.observedAuthenticatedDisconnect = true
+ }
this.connected = false
+ this.daemonIdentity = null
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
@@ -377,3 +490,40 @@ export class DaemonClient {
cleanup?.()
}
}
+
+function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null {
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown }
+ if (
+ !Number.isSafeInteger(identity.pid) ||
+ (identity.pid as number) <= 0 ||
+ typeof identity.startedAtMs !== 'number' ||
+ !Number.isFinite(identity.startedAtMs) ||
+ identity.startedAtMs <= 0 ||
+ typeof identity.launchNonce !== 'string' ||
+ identity.launchNonce.length === 0
+ ) {
+ return null
+ }
+ return {
+ pid: identity.pid as number,
+ startedAtMs: identity.startedAtMs,
+ launchNonce: identity.launchNonce
+ }
+}
+
+function sameDaemonIdentity(
+ left: DaemonEndpointIdentity | null,
+ right: DaemonEndpointIdentity | null
+): boolean {
+ return (
+ (left === null && right === null) ||
+ (left !== null &&
+ right !== null &&
+ left.pid === right.pid &&
+ left.startedAtMs === right.startedAtMs &&
+ left.launchNonce === right.launchNonce)
+ )
+}
diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts
index e24f1c8b3fe..deedb82a2c8 100644
--- a/src/main/daemon/daemon-entry.test.ts
+++ b/src/main/daemon/daemon-entry.test.ts
@@ -51,6 +51,49 @@ describe('daemon-entry parseArgs', () => {
})
})
+ it('parses the internal PID-record ownership pair', () => {
+ expect(
+ parseArgs([
+ '--socket',
+ '/tmp/t.sock',
+ '--token',
+ '/tmp/t.token',
+ '--pid-record',
+ '/tmp/t.pid',
+ '--launch-nonce',
+ 'launch-a'
+ ])
+ ).toEqual({
+ socketPath: '/tmp/t.sock',
+ tokenPath: '/tmp/t.token',
+ pidPath: '/tmp/t.pid',
+ launchNonce: 'launch-a'
+ })
+ })
+
+ it('rejects either PID-record ownership argument without its pair', () => {
+ expect(() =>
+ parseArgs([
+ '--socket',
+ '/tmp/t.sock',
+ '--token',
+ '/tmp/t.token',
+ '--pid-record',
+ '/tmp/t.pid'
+ ])
+ ).toThrow('provided together')
+ expect(() =>
+ parseArgs([
+ '--socket',
+ '/tmp/t.sock',
+ '--token',
+ '/tmp/t.token',
+ '--launch-nonce',
+ 'launch-a'
+ ])
+ ).toThrow('provided together')
+ })
+
it('still requires --socket and --token when --log-file is given', () => {
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
})
diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts
index 4d5207276e0..12c5ad8dd29 100644
--- a/src/main/daemon/daemon-entry.ts
+++ b/src/main/daemon/daemon-entry.ts
@@ -17,6 +17,8 @@ import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
export type ParsedDaemonArgs = {
socketPath: string
tokenPath: string
+ pidPath?: string
+ launchNonce?: string
/** Optional — absent for adopted old daemons and tests, which log nothing. */
logFilePath?: string
}
@@ -25,6 +27,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
let socketPath = ''
let tokenPath = ''
let logFilePath = ''
+ let pidPath = ''
+ let launchNonce = ''
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--socket' && argv[i + 1]) {
@@ -36,6 +40,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
} else if (argv[i] === '--log-file' && argv[i + 1]) {
logFilePath = argv[i + 1]
i++
+ } else if (argv[i] === '--pid-record' && argv[i + 1]) {
+ pidPath = argv[i + 1]
+ i++
+ } else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
+ launchNonce = argv[i + 1]
+ i++
}
}
@@ -43,7 +53,16 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
throw new Error('Usage: daemon-entry --socket --token [--log-file ]')
}
- return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath }
+ if ((pidPath && !launchNonce) || (!pidPath && launchNonce)) {
+ throw new Error('Daemon PID record path and launch nonce must be provided together')
+ }
+
+ return {
+ socketPath,
+ tokenPath,
+ ...(pidPath ? { pidPath, launchNonce } : {}),
+ ...(logFilePath ? { logFilePath } : {})
+ }
}
async function main(): Promise {
@@ -54,7 +73,10 @@ async function main(): Promise {
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
process.stderr.on('error', () => {})
- const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2))
+ const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
+ process.argv.slice(2)
+ )
+ const startedAtMs = Date.now() - process.uptime() * 1000
// Fail-open: a broken log path must never block daemon startup.
const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog()
daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath })
@@ -126,16 +148,25 @@ async function main(): Promise {
daemon = await startDaemon({
socketPath,
tokenPath,
+ ...(pidPath ? { pidPath } : {}),
+ ...(launchNonce ? { launchNonce } : {}),
+ ...(pidPath ? { startedAtMs } : {}),
log: daemonLog,
preparePtySpawn: prepareMacosTccLoginShell,
- spawnSubprocess: (opts) => createPtySubprocess(opts)
+ spawnSubprocess: (opts) => createPtySubprocess(opts),
+ onIdleShutdown: () => {
+ shuttingDown = true
+ daemonLog.log('shutdown', { reason: 'idle' })
+ daemonLog.close()
+ process.exit(0)
+ }
})
// Signal readiness to parent via IPC (if available)
if (process.send) {
// Why: Windows has no cheap OS query for a child's start time, so the
// daemon self-reports it here for the pid file's pid-recycling guard.
- process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 })
+ process.send({ type: 'ready', startedAtMs })
}
daemonLog.log('ready')
diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
index 4fbb3047d6a..028fb8f0c4c 100644
--- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
+++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
@@ -3,7 +3,9 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
- expect(PROTOCOL_VERSION).toBeGreaterThan(19)
+ expect(PROTOCOL_VERSION).toBe(24)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
+ expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
+ expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
})
})
diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts
new file mode 100644
index 00000000000..4e288e4db9b
--- /dev/null
+++ b/src/main/daemon/daemon-hello-protocol.ts
@@ -0,0 +1,20 @@
+export type HelloMessage = {
+ type: 'hello'
+ version: number
+ token: string
+ clientId: string
+ role: 'control' | 'stream'
+}
+
+export type DaemonEndpointIdentity = {
+ pid: number
+ startedAtMs: number
+ launchNonce: string
+}
+
+export type HelloResponse = {
+ type: 'hello'
+ ok: boolean
+ error?: string
+ daemonIdentity?: DaemonEndpointIdentity
+}
diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts
new file mode 100644
index 00000000000..fa12ba50034
--- /dev/null
+++ b/src/main/daemon/daemon-idle-shutdown.test.ts
@@ -0,0 +1,728 @@
+import { EventEmitter } from 'node:events'
+import { connect, type Socket } from 'node:net'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DaemonClient } from './client'
+import { DaemonPtyAdapter } from './daemon-pty-adapter'
+import { DaemonServer } from './daemon-server'
+import { PROTOCOL_VERSION } from './types'
+import {
+ getDaemonPidPath,
+ getDaemonSocketPath,
+ serializeDaemonPidFile,
+ unlinkOwnedDaemonPidFile
+} from './daemon-spawner'
+import type { SubprocessHandle } from './session'
+
+type ManualTimer = {
+ callback: () => void
+ dueAt: number
+ cancelled: boolean
+}
+
+class ManualIdleClock {
+ private nowMs = 0
+ private timers = new Set()
+
+ setTimeout(callback: () => void, delayMs: number): ManualTimer {
+ const timer = { callback, dueAt: this.nowMs + delayMs, cancelled: false }
+ this.timers.add(timer)
+ return timer
+ }
+
+ clearTimeout(handle: unknown): void {
+ const timer = handle as ManualTimer
+ timer.cancelled = true
+ this.timers.delete(timer)
+ }
+
+ now(): number {
+ return this.nowMs
+ }
+
+ advanceBy(ms: number): void {
+ this.nowMs += ms
+ for (const timer of [...this.timers].sort((a, b) => a.dueAt - b.dueAt)) {
+ if (timer.cancelled || timer.dueAt > this.nowMs) {
+ continue
+ }
+ this.timers.delete(timer)
+ timer.callback()
+ }
+ }
+
+ get pendingCount(): number {
+ return this.timers.size
+ }
+}
+
+function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } {
+ let onExit: ((code: number) => void) | null = null
+ return {
+ pid: 9345,
+ getForegroundProcess: () => null,
+ write: vi.fn(),
+ resize: vi.fn(),
+ kill: vi.fn(),
+ forceKill: vi.fn(),
+ signal: vi.fn(),
+ onData: vi.fn(),
+ onExit(callback) {
+ onExit = callback
+ },
+ dispose: vi.fn(),
+ exit(code) {
+ onExit?.(code)
+ }
+ }
+}
+
+async function waitFor(predicate: () => boolean): Promise {
+ const deadline = Date.now() + 2_000
+ while (!predicate()) {
+ if (Date.now() >= deadline) {
+ throw new Error('Timed out waiting for daemon idle state')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+}
+
+async function requestOnRawSocket(
+ socket: Socket,
+ request: { id: string; type: string; payload: unknown }
+): Promise<{ error?: string }> {
+ return new Promise((resolve, reject) => {
+ let buffer = ''
+ const cleanup = (): void => {
+ clearTimeout(timeout)
+ socket.off('data', onData)
+ }
+ const onData = (chunk: Buffer): void => {
+ buffer += chunk.toString('utf8')
+ for (;;) {
+ const newlineIndex = buffer.indexOf('\n')
+ if (newlineIndex === -1) {
+ return
+ }
+ const line = buffer.slice(0, newlineIndex)
+ buffer = buffer.slice(newlineIndex + 1)
+ const message = JSON.parse(line) as { id?: string; error?: string }
+ if (message.id === request.id) {
+ cleanup()
+ resolve(message)
+ return
+ }
+ }
+ }
+ const timeout = setTimeout(() => {
+ cleanup()
+ reject(new Error(`Timed out waiting for raw response ${request.id}`))
+ }, 2_000)
+ socket.on('data', onData)
+ socket.write(`${JSON.stringify(request)}\n`)
+ })
+}
+
+describe('current daemon lifecycle retirement', () => {
+ let dir: string
+ let socketPath: string
+ let tokenPath: string
+ let pidPath: string
+ let clock: ManualIdleClock
+ let server: DaemonServer | null
+ let subprocess: ReturnType
+ let onIdleShutdown: ReturnType void>>
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'daemon-idle-shutdown-'))
+ socketPath = getDaemonSocketPath(dir)
+ tokenPath = join(dir, 'daemon.token')
+ pidPath = getDaemonPidPath(dir)
+ clock = new ManualIdleClock()
+ subprocess = createMockSubprocess()
+ onIdleShutdown = vi.fn<() => void>()
+ server = null
+ })
+
+ afterEach(async () => {
+ await server?.shutdown().catch(() => {})
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ async function startServer(
+ options: {
+ launchNonce?: string
+ protocolVersion?: number
+ } = {}
+ ): Promise {
+ server = new DaemonServer({
+ socketPath,
+ tokenPath,
+ ...(options.launchNonce ? { pidPath, launchNonce: options.launchNonce } : {}),
+ ...(options.protocolVersion !== undefined
+ ? { protocolVersion: options.protocolVersion }
+ : {}),
+ initialAdoptionTestConfig: { timeoutMs: 100, clock },
+ onIdleShutdown,
+ spawnSubprocess: () => subprocess
+ })
+ await server.start()
+ }
+
+ it('retires immediately after an unexpected empty disconnect and removes owned artifacts', async () => {
+ const launchNonce = 'launch-a'
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce })
+ )
+ await startServer({ launchNonce })
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ client.disconnect()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+
+ expect(clock.pendingCount).toBe(0)
+ expect(existsSync(tokenPath)).toBe(false)
+ expect(existsSync(pidPath)).toBe(false)
+ if (process.platform !== 'win32') {
+ expect(existsSync(socketPath)).toBe(false)
+ }
+ })
+
+ it('retires a fresh daemon that is never adopted by a full client pair', async () => {
+ await startServer()
+
+ expect(clock.pendingCount).toBe(1)
+ clock.advanceBy(100)
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('retires immediately after an authenticated clean disconnect proves it is empty', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
+ retiring: true
+ })
+ client.disconnect()
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(clock.pendingCount).toBe(0)
+ })
+
+ it('keeps resources alive until the shutdownIfIdle reply write flushes', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const daemon = server as unknown as {
+ clients: Map
+ host: { dispose: () => Promise }
+ }
+ const controlSocket = [...daemon.clients.values()][0].controlSocket
+ const originalWrite = controlSocket.write.bind(controlSocket)
+ let replyFlushed: (() => void) | undefined
+ vi.spyOn(controlSocket, 'write').mockImplementation(((
+ chunk: string | Uint8Array,
+ ...args: unknown[]
+ ) => {
+ replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined
+ return originalWrite(chunk)
+ }) as unknown as Socket['write'])
+ const dispose = vi.spyOn(daemon.host, 'dispose')
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true })
+ expect(dispose).not.toHaveBeenCalled()
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+
+ replyFlushed?.()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(dispose).toHaveBeenCalledOnce()
+ client.disconnect()
+ })
+
+ it('finishes shutdownIfIdle when the peer closes before its reply callback', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const daemon = server as unknown as {
+ clients: Map
+ host: { dispose: () => Promise }
+ }
+ const controlSocket = [...daemon.clients.values()][0].controlSocket
+ const originalWrite = controlSocket.write.bind(controlSocket)
+ vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) =>
+ originalWrite(chunk)) as unknown as Socket['write'])
+ const dispose = vi.spyOn(daemon.host, 'dispose')
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true })
+ expect(dispose).not.toHaveBeenCalled()
+ client.disconnect()
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(dispose).toHaveBeenCalledOnce()
+ })
+
+ it('connects and retires a never-used adapter during clean disconnect', async () => {
+ await startServer()
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
+
+ await adapter.disconnectOnly()
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(clock.pendingCount).toBe(0)
+ })
+
+ it('preserves a live session after clean detach and retires when that session exits', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ await client.request('createOrAttach', { sessionId: 'preserved', cols: 80, rows: 24 })
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
+ retiring: false
+ })
+ client.disconnect()
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ subprocess.exit(0)
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(clock.pendingCount).toBe(0)
+ })
+
+ it('rejects clean retirement while create or attach is in flight', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const daemon = server as unknown as { createOrAttachInFlight: number }
+ daemon.createOrAttachInFlight = 1
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
+ retiring: false
+ })
+
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ client.disconnect()
+ })
+
+ it('rejects clean retirement when an unknown transport is connected', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const rawSocket = connect(socketPath)
+ await new Promise((resolve) => rawSocket.once('connect', resolve))
+
+ await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
+ retiring: false
+ })
+ rawSocket.destroy()
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ client.disconnect()
+ })
+
+ it('rejects clean retirement while another authenticated client is connected', async () => {
+ await startServer()
+ const first = new DaemonClient({ socketPath, tokenPath })
+ const second = new DaemonClient({ socketPath, tokenPath })
+ await Promise.all([first.ensureConnected(), second.ensureConnected()])
+
+ await expect(first.request('shutdownIfIdle', undefined)).resolves.toEqual({
+ retiring: false
+ })
+
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ first.disconnect()
+ second.disconnect()
+ })
+
+ it("preserves another client's live session when failed adoption disconnects", async () => {
+ await startServer()
+ const adoptingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
+ const liveOwner = new DaemonClient({ socketPath, tokenPath })
+ await Promise.all([adoptingAdapter.establishLifecycleLease(), liveOwner.ensureConnected()])
+ await liveOwner.request('createOrAttach', {
+ sessionId: 'owned-by-second-client',
+ cols: 80,
+ rows: 24
+ })
+
+ await adoptingAdapter.disconnectOnly()
+
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ await expect(liveOwner.request('listSessions', undefined)).resolves.toMatchObject({
+ sessions: [expect.objectContaining({ sessionId: 'owned-by-second-client', isAlive: true })]
+ })
+ subprocess.exit(0)
+ liveOwner.disconnect()
+ })
+
+ it('lets an overlapping raw socket block but not erase empty retirement', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const rawSocket = connect(socketPath)
+ await new Promise((resolve) => rawSocket.once('connect', resolve))
+ client.disconnect()
+
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+
+ rawSocket.destroy()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(clock.pendingCount).toBe(0)
+ })
+
+ it('retires after the last authenticated client disconnects with no sessions', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ expect(clock.pendingCount).toBe(0)
+
+ client.disconnect()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ expect(clock.pendingCount).toBe(0)
+ })
+
+ it('lets a complete reconnect cancel retirement while a live session blocks it', async () => {
+ await startServer()
+ const first = new DaemonClient({ socketPath, tokenPath })
+ await first.ensureConnected()
+ await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 })
+ first.disconnect()
+ const daemon = server as unknown as { retirementRequested: boolean }
+ await waitFor(() => daemon.retirementRequested)
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+
+ const second = new DaemonClient({ socketPath, tokenPath })
+ await second.ensureConnected()
+ await waitFor(() => !daemon.retirementRequested)
+ subprocess.exit(0)
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+
+ second.disconnect()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('cancels the initial-adoption timeout before the adapter creates its first terminal', async () => {
+ await startServer()
+ await waitFor(() => clock.pendingCount === 1)
+ clock.advanceBy(50)
+
+ const adopted = new DaemonPtyAdapter({ socketPath, tokenPath })
+ await adopted.establishLifecycleLease()
+ expect(clock.pendingCount).toBe(0)
+
+ clock.advanceBy(100)
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ await expect(
+ adopted.spawn({
+ sessionId: 'first-after-adoption',
+ cols: 80,
+ rows: 24
+ })
+ ).resolves.toMatchObject({ id: 'first-after-adoption' })
+ subprocess.exit(0)
+ adopted.dispose()
+ })
+
+ it('does not let repeated authenticated control probes extend the startup deadline', async () => {
+ await startServer()
+ const healthControl = connect(socketPath)
+ await new Promise((resolve) => healthControl.once('connect', resolve))
+ healthControl.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: PROTOCOL_VERSION,
+ token: readFileSync(tokenPath, 'utf8').trim(),
+ clientId: 'startup-health-control',
+ role: 'control'
+ })}\n`
+ )
+ const daemon = server as unknown as { clients: Map }
+ await waitFor(() => daemon.clients.has('startup-health-control'))
+ clock.advanceBy(1_000)
+ healthControl.destroy()
+
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('does not let a control-only create cancel the initial-adoption timeout', async () => {
+ await startServer()
+ const control = connect(socketPath)
+ await new Promise((resolve) => control.once('connect', resolve))
+ control.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: PROTOCOL_VERSION,
+ token: readFileSync(tokenPath, 'utf8').trim(),
+ clientId: 'startup-control-create',
+ role: 'control'
+ })}\n`
+ )
+ const daemon = server as unknown as {
+ clients: Map
+ retirementRequested: boolean
+ }
+ await waitFor(() => daemon.clients.has('startup-control-create'))
+ const response = await requestOnRawSocket(control, {
+ id: 'control-only-create',
+ type: 'createOrAttach',
+ payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
+ })
+ expect(response.error).toContain('connection is incomplete')
+ expect(daemon.retirementRequested).toBe(false)
+ expect(clock.pendingCount).toBe(0)
+
+ clock.advanceBy(1_000)
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+
+ control.destroy()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('keeps empty-retirement intent when a control-only client overlaps the last app', async () => {
+ await startServer()
+ const paired = new DaemonClient({ socketPath, tokenPath })
+ await paired.ensureConnected()
+ const incomplete = connect(socketPath)
+ await new Promise((resolve) => incomplete.once('connect', resolve))
+ incomplete.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: PROTOCOL_VERSION,
+ token: readFileSync(tokenPath, 'utf8').trim(),
+ clientId: 'control-only-overlap',
+ role: 'control'
+ })}\n`
+ )
+ const daemon = server as unknown as {
+ clients: Map
+ retirementRequested: boolean
+ }
+ await waitFor(() => daemon.clients.has('control-only-overlap'))
+
+ paired.disconnect()
+ await waitFor(() => daemon.retirementRequested)
+ expect(clock.pendingCount).toBe(0)
+ const response = await requestOnRawSocket(incomplete, {
+ id: 'overlap-control-create',
+ type: 'createOrAttach',
+ payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
+ })
+ expect(response.error).toContain('connection is incomplete')
+ expect(daemon.retirementRequested).toBe(true)
+
+ incomplete.destroy()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('keeps retirement intent when a same-client replacement never completes its stream', async () => {
+ await startServer()
+ const paired = new DaemonClient({ socketPath, tokenPath })
+ await paired.ensureConnected()
+ const clientId = (paired as unknown as { clientId: string }).clientId
+ const replacementControl = connect(socketPath)
+ await new Promise((resolve) => replacementControl.once('connect', resolve))
+ replacementControl.write(
+ `${JSON.stringify({
+ type: 'hello',
+ version: PROTOCOL_VERSION,
+ token: readFileSync(tokenPath, 'utf8').trim(),
+ clientId,
+ role: 'control'
+ })}\n`
+ )
+ const daemon = server as unknown as {
+ retirementRequested: boolean
+ }
+ await waitFor(() => daemon.retirementRequested)
+ expect(clock.pendingCount).toBe(0)
+
+ replacementControl.destroy()
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('keeps a live session after clients disconnect, then retires on its exit', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 })
+ client.disconnect()
+ const daemon = server as unknown as { retirementRequested: boolean }
+ await waitFor(() => daemon.retirementRequested)
+
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ expect(clock.pendingCount).toBe(0)
+
+ subprocess.exit(0)
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+ })
+
+ it('uses the direct-construction protocol fixture version for hello compatibility', async () => {
+ await startServer({ protocolVersion: 22 })
+ const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 22 })
+
+ await expect(client.ensureConnected()).resolves.toBeUndefined()
+ client.disconnect()
+ })
+
+ it('requests clean retirement only for protocol v24 and newer adapters', async () => {
+ await startServer()
+ const current = new DaemonPtyAdapter({ socketPath, tokenPath })
+ await current.listProcesses()
+ const currentClient = (
+ current as unknown as {
+ client: DaemonClient
+ }
+ ).client
+ const currentRequest = vi.spyOn(currentClient, 'request')
+
+ await current.disconnectOnly()
+
+ expect(currentRequest).toHaveBeenCalledOnce()
+ expect(currentRequest.mock.calls[0]?.slice(0, 2)).toEqual(['shutdownIfIdle', undefined])
+ const timeoutMs = currentRequest.mock.calls[0]?.[2]
+ // Why: connection and RPC share a wall-clock budget, so elapsed setup time is expected.
+ expect(timeoutMs).toEqual(expect.any(Number))
+ expect(timeoutMs).toBeGreaterThan(0)
+ expect(timeoutMs).toBeLessThanOrEqual(250)
+ })
+
+ it('does not send the v24 clean-disconnect RPC to a legacy daemon', async () => {
+ await startServer({ protocolVersion: 23 })
+ const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 })
+ await legacy.listProcesses()
+ const legacyClient = (
+ legacy as unknown as {
+ client: DaemonClient
+ }
+ ).client
+ const legacyRequest = vi.spyOn(legacyClient, 'request')
+
+ await legacy.disconnectOnly()
+
+ expect(legacyRequest.mock.calls.map(([type]) => type)).not.toContain('shutdownIfIdle')
+ })
+
+ it('rejects create or attach once the idle admission fence is pending', async () => {
+ await startServer()
+ const daemon = server as unknown as {
+ idleShutdownState: string
+ routeRequest(clientId: string, request: unknown): Promise
+ }
+ daemon.idleShutdownState = 'idle-shutdown-pending'
+
+ await expect(
+ daemon.routeRequest('late-client', {
+ id: 'late-create',
+ type: 'createOrAttach',
+ payload: { sessionId: 'late', cols: 80, rows: 24 }
+ })
+ ).rejects.toThrow('temporarily unavailable; reconnect')
+ })
+
+ it('aborts the pending shutdown when create or attach started before the fence', async () => {
+ await startServer()
+ const daemon = server as unknown as {
+ createOrAttachInFlight: number
+ idleShutdownState: string
+ beginIdleShutdown(): void
+ }
+ daemon.createOrAttachInFlight = 1
+
+ daemon.beginIdleShutdown()
+
+ expect(daemon.idleShutdownState).toBe('running')
+ expect(onIdleShutdown).not.toHaveBeenCalled()
+ })
+
+ it('explicitly marks a post-fence accepted transport as retryable', async () => {
+ await startServer()
+ const daemon = server as unknown as {
+ idleShutdownState: string
+ handleConnection(socket: Socket): void
+ }
+ daemon.idleShutdownState = 'idle-shutdown-pending'
+ const socket = new EventEmitter() as Socket
+ socket.end = vi.fn() as unknown as Socket['end']
+
+ daemon.handleConnection(socket)
+
+ const payload = vi.mocked(socket.end).mock.calls[0]?.[0]
+ expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true })
+ socket.emit('close')
+ })
+
+ it('preserves a replacement PID record during otherwise successful idle cleanup', async () => {
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce: 'replacement' })
+ )
+ await startServer({ launchNonce: 'mine' })
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+
+ await client.request('shutdownIfIdle', undefined)
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+
+ expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
+ pid: process.pid,
+ launchNonce: 'replacement'
+ })
+ })
+
+ it('preserves a token file replaced before idle cleanup', async () => {
+ await startServer()
+ const client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ writeFileSync(tokenPath, 'replacement-token')
+
+ await client.request('shutdownIfIdle', undefined)
+ await waitFor(() => onIdleShutdown.mock.calls.length === 1)
+
+ expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token')
+ })
+})
+
+describe('daemon PID record ownership cleanup', () => {
+ let dir: string
+ let pidPath: string
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'daemon-pid-ownership-'))
+ pidPath = join(dir, 'daemon.pid')
+ })
+
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
+
+ it('unlinks only an exact PID and launch nonce match', () => {
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'mine' })
+ )
+
+ expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(true)
+ expect(existsSync(pidPath)).toBe(false)
+ })
+
+ it.each([
+ ['malformed', '{'],
+ ['stale PID', serializeDaemonPidFile({ pid: 456, startedAtMs: null, launchNonce: 'mine' })],
+ [
+ 'replacement nonce',
+ serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'new' })
+ ]
+ ])('preserves a %s record', (_label, contents) => {
+ writeFileSync(pidPath, contents)
+
+ expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false)
+ expect(readFileSync(pidPath, 'utf8')).toBe(contents)
+ })
+
+ it('leaves a missing record missing', () => {
+ expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false)
+ expect(existsSync(pidPath)).toBe(false)
+ })
+})
diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts
index 89493f675bf..9bc78a4da75 100644
--- a/src/main/daemon/daemon-init.test.ts
+++ b/src/main/daemon/daemon-init.test.ts
@@ -38,11 +38,17 @@ const {
killStaleDaemonMock,
getProcessStartedAtMsMock,
parseDaemonPidFileMock,
+ unlinkOwnedDaemonPidFileMock,
daemonClientMock,
spawnerInstances,
ensureRunningOverrides,
+ adoptionLeaseReleases,
+ lifecycleLeaseErrors,
+ disconnectOnlyErrors,
+ routerSubscriptionError,
adapterInstances,
defaultListSessionsSessions,
+ listProcessesControl,
getLocalPtyProviderMock,
localFallbackProvider,
setLocalPtyProviderMock,
@@ -98,6 +104,7 @@ const {
const parseDaemonPidFileMock = vi.fn(
(): { pid: number; startedAtMs: number | null } | null => null
)
+ const unlinkOwnedDaemonPidFileMock = vi.fn(() => true)
const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() {
return {
@@ -115,12 +122,19 @@ const {
tokenPath: string
mode?: 'degraded-new-pty-fallback'
}>)[] = []
+ const adoptionLeaseReleases: ReturnType[] = []
+ const lifecycleLeaseErrors: Error[] = []
+ const disconnectOnlyErrors: Error[] = []
+ const routerSubscriptionError: { current: Error | null } = { current: null }
// Same for DaemonPtyAdapter. The test asserts the replacement adapter is a
// fresh instance whose respawn closure targets the *original* spawner.
const adapterInstances: MockAdapter[] = []
// Why: adapters are constructed inside initDaemonPtyProvider, so tests that
// need listSessions to report live sessions set this before calling init.
const defaultListSessionsSessions: { sessionId: string }[] = []
+ const listProcessesControl: {
+ current: null | (() => Promise<{ sessionId: string }[]>)
+ } = { current: null }
const localFallbackProvider = {
routesFreshSpawnsToLocalProvider: undefined,
@@ -171,11 +185,17 @@ const {
killStaleDaemonMock,
getProcessStartedAtMsMock,
parseDaemonPidFileMock,
+ unlinkOwnedDaemonPidFileMock,
daemonClientMock,
spawnerInstances,
ensureRunningOverrides,
+ adoptionLeaseReleases,
+ lifecycleLeaseErrors,
+ disconnectOnlyErrors,
+ routerSubscriptionError,
adapterInstances,
defaultListSessionsSessions,
+ listProcessesControl,
getLocalPtyProviderMock,
localFallbackProvider,
setLocalPtyProviderMock,
@@ -205,6 +225,7 @@ type MockAdapter = {
fanoutSyntheticExits: ReturnType
listProcesses: ReturnType
listSessions: ReturnType
+ establishLifecycleLease: ReturnType
shutdown: ReturnType
dispose: ReturnType
disconnectOnly: ReturnType
@@ -261,7 +282,11 @@ vi.mock('./daemon-spawner', () => ({
readonly shutdown: ReturnType
readonly getHandle: ReturnType
private socketCounter: number
- private handle: { mode?: 'degraded-new-pty-fallback'; shutdown: () => Promise } | null
+ private handle: {
+ mode?: 'degraded-new-pty-fallback'
+ releaseAdoptionLease?: () => void
+ shutdown: () => Promise
+ } | null
constructor(opts: { runtimeDir: string; launcher: unknown }) {
this.launcher = opts.launcher
this.socketCounter = 0
@@ -273,7 +298,9 @@ vi.mock('./daemon-spawner', () => ({
const override = ensureRunningOverrides.shift()
if (override) {
const result = await override()
- this.handle = { shutdown: vi.fn(async () => {}) }
+ const releaseAdoptionLease = vi.fn()
+ adoptionLeaseReleases.push(releaseAdoptionLease)
+ this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) }
if (result.mode) {
this.handle.mode = result.mode
}
@@ -283,7 +310,9 @@ vi.mock('./daemon-spawner', () => ({
}
}
this.socketCounter += 1
- this.handle = { shutdown: vi.fn(async () => {}) }
+ const releaseAdoptionLease = vi.fn()
+ adoptionLeaseReleases.push(releaseAdoptionLease)
+ this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) }
return {
socketPath: `/fake/socket-${this.socketCounter}`,
tokenPath: `/fake/token-${this.socketCounter}`
@@ -301,7 +330,8 @@ vi.mock('./daemon-spawner', () => ({
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`,
getDaemonPidPath: (_dir: string, version?: number) =>
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`,
- serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj)
+ serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj),
+ unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock
}))
vi.mock('./daemon-pty-adapter', () => ({
@@ -312,6 +342,7 @@ vi.mock('./daemon-pty-adapter', () => ({
readonly fanoutSyntheticExits: ReturnType
readonly listProcesses: ReturnType
readonly listSessions: ReturnType
+ readonly establishLifecycleLease: ReturnType
readonly shutdown: ReturnType
readonly dispose: ReturnType
readonly disconnectOnly: ReturnType
@@ -326,12 +357,32 @@ vi.mock('./daemon-pty-adapter', () => ({
this.fanoutSyntheticExits = vi.fn(() => {
this.callOrder.push('fanoutSyntheticExits')
})
- this.listProcesses = vi.fn(async () => [])
+ this.listProcesses = vi.fn(async () =>
+ listProcessesControl.current ? listProcessesControl.current() : []
+ )
this.listSessions = vi.fn(async () => [...defaultListSessionsSessions])
+ const lifecycleLeaseError = lifecycleLeaseErrors.shift()
+ this.establishLifecycleLease = vi.fn(async () => {
+ if (lifecycleLeaseError) {
+ throw lifecycleLeaseError
+ }
+ })
this.shutdown = vi.fn(async () => {})
this.dispose = vi.fn()
- this.disconnectOnly = vi.fn(async () => {})
- this.onData = vi.fn(() => () => {})
+ const disconnectOnlyError = disconnectOnlyErrors.shift()
+ this.disconnectOnly = vi.fn(async () => {
+ if (disconnectOnlyError) {
+ throw disconnectOnlyError
+ }
+ })
+ this.onData = vi.fn(() => {
+ if (routerSubscriptionError.current) {
+ const error = routerSubscriptionError.current
+ routerSubscriptionError.current = null
+ throw error
+ }
+ return () => {}
+ })
this.onExit = vi.fn(() => () => {})
adapterInstances.push(this as unknown as MockAdapter)
}
@@ -349,8 +400,13 @@ async function importFresh() {
vi.resetModules()
spawnerInstances.length = 0
ensureRunningOverrides.length = 0
+ adoptionLeaseReleases.length = 0
+ lifecycleLeaseErrors.length = 0
+ disconnectOnlyErrors.length = 0
+ routerSubscriptionError.current = null
adapterInstances.length = 0
defaultListSessionsSessions.length = 0
+ listProcessesControl.current = null
getLocalPtyProviderMock.mockClear()
localFallbackProvider.spawn.mockClear()
localFallbackProvider.write.mockClear()
@@ -374,7 +430,14 @@ async function importFresh() {
forkMock.mockReset()
isPackagedMock.mockReset()
isPackagedMock.mockReturnValue(false)
- daemonClientMock.mockClear()
+ daemonClientMock.mockReset()
+ daemonClientMock.mockImplementation(function MockDaemonClient() {
+ return {
+ ensureConnected: vi.fn(async () => {}),
+ request: vi.fn(async () => ({ sessions: [] })),
+ disconnect: vi.fn()
+ }
+ })
probeSocketExistsMock.mockClear()
writeFileSyncMock.mockClear()
readFileSyncMock.mockReset()
@@ -384,6 +447,8 @@ async function importFresh() {
unlinkSyncMock.mockClear()
parseDaemonPidFileMock.mockReset()
parseDaemonPidFileMock.mockReturnValue(null)
+ unlinkOwnedDaemonPidFileMock.mockReset()
+ unlinkOwnedDaemonPidFileMock.mockReturnValue(true)
getProcessStartedAtMsMock.mockReset()
getProcessStartedAtMsMock.mockReturnValue(1_000_000)
// Why: importing daemon-init *after* resetModules means the module-level
@@ -393,6 +458,16 @@ async function importFresh() {
return import('./daemon-init')
}
+function mockConnectedAdoptionClientOnce(): void {
+ daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
+ return {
+ ensureConnected: vi.fn(async () => {}),
+ request: vi.fn(),
+ disconnect: vi.fn()
+ }
+ })
+}
+
describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
beforeEach(() => {
probeSocketExistsMock.mockReturnValue(false)
@@ -429,6 +504,50 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan(
setLocalPtyProviderMock.mock.invocationCallOrder[0]
)
+ expect(adapterInstances[0].establishLifecycleLease).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
+ setLocalPtyProviderMock.mock.invocationCallOrder[0]
+ )
+ expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
+ adoptionLeaseReleases[0].mock.invocationCallOrder[0]
+ )
+ })
+
+ it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => {
+ const mod = await importFresh()
+ ensureRunningOverrides.push(async () => ({
+ socketPath: '/fake/launched-socket',
+ tokenPath: '/fake/launched-token'
+ }))
+ lifecycleLeaseErrors.push(new Error('lease identity mismatch'))
+
+ await expect(mod.initDaemonPtyProvider()).rejects.toThrow('lease identity mismatch')
+
+ expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].dispose).not.toHaveBeenCalled()
+ expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
+ expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
+ adoptionLeaseReleases[0].mock.invocationCallOrder[0]
+ )
+ expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
+ })
+
+ it('does not kill a preserved daemon when startup lease acquisition fails', async () => {
+ const mod = await importFresh()
+ ensureRunningOverrides.push(async () => ({
+ socketPath: '/fake/preserved-socket',
+ tokenPath: '/fake/preserved-token'
+ }))
+ lifecycleLeaseErrors.push(new Error('preserved lease failed'))
+
+ await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed')
+
+ expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
+ expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
+ expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
})
it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => {
@@ -474,12 +593,100 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' })
await started
- expect(adapterInstances).toHaveLength(0)
+ expect(adapterInstances).toHaveLength(1)
+ expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
+ expect(adapterInstances[0].establishLifecycleLease).not.toHaveBeenCalled()
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled()
expect(mod.getDaemonProvider()).toBeNull()
})
+ it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => {
+ const mod = await importFresh()
+ probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
+ netConnectMock.mockImplementation(() => {
+ const handlers: Record void)[]> = { connect: [], error: [] }
+ return {
+ on(event: string, cb: () => void) {
+ handlers[event]?.push(cb)
+ if (event === 'connect') {
+ queueMicrotask(() => cb())
+ }
+ return this
+ },
+ removeListener(event: string, cb: () => void) {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
+ return this
+ },
+ destroy() {}
+ }
+ })
+ let resolveDiscovery!: (sessions: { sessionId: string }[]) => void
+ const discovery = new Promise<{ sessionId: string }[]>((resolve) => {
+ resolveDiscovery = resolve
+ })
+ listProcessesControl.current = () => discovery
+ const abortController = new AbortController()
+
+ const started = mod.initDaemonPtyProvider(abortController.signal)
+ await vi.waitFor(() => {
+ expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true)
+ expect(
+ adapterInstances.some((instance) => instance.listProcesses.mock.calls.length > 0)
+ ).toBe(true)
+ })
+ abortController.abort()
+ resolveDiscovery([])
+ await started
+
+ expect(adapterInstances).toHaveLength(2)
+ expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
+ expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
+ expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
+ expect(mod.getDaemonProvider()).toBeNull()
+ })
+
+ it('retains every adapter cleanup failure when legacy router setup aborts', async () => {
+ const mod = await importFresh()
+ probeSocketExistsMock.mockImplementation(
+ (path?: string) => path?.endsWith('daemon-v9.sock') ?? false
+ )
+ netConnectMock.mockImplementation(() => {
+ const handlers: Record void)[]> = { connect: [], error: [] }
+ return {
+ on(event: string, callback: () => void) {
+ handlers[event]?.push(callback)
+ if (event === 'connect') {
+ queueMicrotask(() => callback())
+ }
+ return this
+ },
+ removeListener(event: string, callback: () => void) {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
+ return this
+ },
+ destroy() {}
+ }
+ })
+ const discoveryError = new Error('router subscription failed')
+ const currentCleanupError = new Error('current cleanup failed')
+ const legacyCleanupError = new Error('legacy cleanup failed')
+ routerSubscriptionError.current = discoveryError
+ disconnectOnlyErrors.push(currentCleanupError, legacyCleanupError)
+
+ const error = await mod.initDaemonPtyProvider().catch((caught: unknown) => caught)
+ expect(error).toBeInstanceOf(AggregateError)
+ const topLevelErrors = (error as AggregateError).errors
+ expect(topLevelErrors[0]).toBe(discoveryError)
+ expect(topLevelErrors[1]).toBeInstanceOf(AggregateError)
+ expect((topLevelErrors[1] as AggregateError).errors).toEqual([
+ legacyCleanupError,
+ currentCleanupError
+ ])
+ expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
+ expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
+ })
+
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
@@ -537,6 +744,26 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(order).toEqual(['fanout', 'unbind'])
})
+ it('uses daemon-owned idle retirement after a failed manual-restart adoption', async () => {
+ const mod = await importFresh()
+ await mod.initDaemonPtyProvider()
+ const originalProvider = mod.getDaemonProvider()
+ ensureRunningOverrides.push(async () => ({
+ socketPath: '/fake/restart-failure-socket',
+ tokenPath: '/fake/restart-failure-token'
+ }))
+ lifecycleLeaseErrors.push(new Error('restart lease failed'))
+
+ await expect(mod.restartDaemon()).rejects.toThrow('restart lease failed')
+
+ expect(adoptionLeaseReleases[1]).toHaveBeenCalledOnce()
+ expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
+ expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
+ expect(mod.getDaemonProvider()).toBe(originalProvider)
+ expect(unbindLocalProviderListenersMock).toHaveBeenCalledOnce()
+ expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(2)
+ })
+
it('fans exits for preserved degraded current-daemon sessions during restart', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
@@ -801,6 +1028,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
// result, so its existence proves the ordering.
expect(adapterInstances).toHaveLength(2)
expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2')
+ expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce()
+ expect(adapterInstances[1].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
+ setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) as number
+ )
})
it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => {
@@ -820,9 +1051,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
const ensureConnectedMock = vi.fn(async () => {})
const disconnectMock = vi.fn()
- // Why: DaemonClient is invoked via `new DaemonClient(...)`, so the mock
- // factory must return a constructor-compatible function. Capture the
- // existing impl so later tests aren't affected.
+ const mod = await importFresh()
+ await mod.initDaemonPtyProvider()
daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() {
return {
ensureConnected: ensureConnectedMock,
@@ -831,9 +1061,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
}
})
- const mod = await importFresh()
- await mod.initDaemonPtyProvider()
-
// Make probeSocket return true for the current-version path by toggling
// both the fs.existsSync proxy AND net.connect resolving "alive".
probeSocketExistsMock.mockReturnValue(true)
@@ -996,7 +1223,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1036,6 +1263,79 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
)
})
+ it('holds a full adoption pair before a healthy launcher resolves', async () => {
+ const mod = await importFresh()
+ await mod.initDaemonPtyProvider()
+ const events: string[] = []
+ const disconnect = vi.fn()
+ daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
+ return {
+ ensureConnected: vi.fn(async () => {
+ events.push('full-pair')
+ }),
+ request: vi.fn(),
+ disconnect
+ }
+ })
+ checkDaemonHealthMock.mockImplementationOnce(async () => {
+ events.push('health')
+ return 'healthy'
+ })
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{
+ releaseAdoptionLease?(): void
+ shutdown(): Promise
+ }>
+
+ const handle = await launcher('/fake/socket', '/fake/token')
+
+ expect(events[0]).toBe('full-pair')
+ expect(events.indexOf('full-pair')).toBeLessThan(events.indexOf('health'))
+ expect(disconnect).not.toHaveBeenCalled()
+ handle.releaseAdoptionLease?.()
+ expect(disconnect).toHaveBeenCalledOnce()
+ })
+
+ it('disconnects every temporary client when healthy adoption fails', async () => {
+ const mod = await importFresh()
+ await mod.initDaemonPtyProvider()
+ const initialDisconnect = vi.fn()
+ const replacementDisconnect = vi.fn()
+ daemonClientMock
+ .mockImplementationOnce(function MockInitialAdoptionClient() {
+ return {
+ ensureConnected: vi.fn(async () => {
+ throw new Error('initial adoption failed')
+ }),
+ request: vi.fn(),
+ disconnect: initialDisconnect
+ }
+ })
+ .mockImplementationOnce(function MockReplacementAdoptionClient() {
+ return {
+ ensureConnected: vi.fn(async () => {
+ throw new Error('replacement adoption failed')
+ }),
+ request: vi.fn(),
+ disconnect: replacementDisconnect
+ }
+ })
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+
+ await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
+ 'replacement adoption failed'
+ )
+
+ expect(initialDisconnect).toHaveBeenCalledOnce()
+ expect(replacementDisconnect).toHaveBeenCalledOnce()
+ expect(forkMock).not.toHaveBeenCalled()
+ })
+
it('preserves a daemon launched from another app path when it owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1052,6 +1352,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1091,6 +1392,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1133,7 +1435,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1185,6 +1487,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1220,6 +1523,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1264,7 +1568,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1316,7 +1620,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1336,16 +1640,355 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(handlers.exit).toHaveLength(0)
expect(child.disconnect).toHaveBeenCalledOnce()
expect(child.unref).toHaveBeenCalledOnce()
- expect(writeFileSyncMock).toHaveBeenCalledWith(
- `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
- JSON.stringify({
- pid: 12345,
- startedAtMs: 1_000_000,
- entryPath: FAKE_DAEMON_ENTRY_PATH,
- appVersion: '1.2.3'
- }),
- { mode: 0o600 }
+ const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? []
+ expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`)
+ expect(JSON.parse(pidContents as string)).toEqual({
+ pid: 12345,
+ startedAtMs: 1_000_000,
+ entryPath: FAKE_DAEMON_ENTRY_PATH,
+ appVersion: '1.2.3',
+ launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/)
+ })
+ expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' })
+ const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[]
+ const launchNonceIndex = launchArgs.indexOf('--launch-nonce')
+ expect(launchArgs).toEqual(
+ expect.arrayContaining([
+ '--pid-record',
+ `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
+ '--launch-nonce'
+ ])
)
+ expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce)
+ })
+
+ it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+ const adoptionDisconnects: ReturnType[] = []
+ function MockFailingAdoptionClient() {
+ const disconnect = vi.fn()
+ adoptionDisconnects.push(disconnect)
+ return {
+ ensureConnected: vi.fn(async () => {
+ throw new Error('adoption unavailable')
+ }),
+ request: vi.fn(),
+ disconnect
+ }
+ }
+ for (let index = 0; index < 3; index++) {
+ daemonClientMock.mockImplementationOnce(MockFailingAdoptionClient)
+ }
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null as number | null,
+ signalCode: null as NodeJS.Signals | null,
+ on(event: string, callback: (arg?: unknown) => void) {
+ handlers[event]?.push(callback)
+ if (event === 'message') {
+ queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 }))
+ }
+ return this
+ },
+ once(event: string, callback: (arg?: unknown) => void) {
+ handlers[event]?.push(callback)
+ return this
+ },
+ off(event: string, callback: (arg?: unknown) => void) {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
+ return this
+ },
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string,
+ pidPath?: string,
+ launchNonce?: string
+ ) => Promise<{ shutdown(): Promise }>
+
+ await expect(
+ launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed')
+ ).rejects.toThrow('adoption unavailable')
+
+ expect(writeFileSyncMock).toHaveBeenCalledWith(
+ '/fake/daemon.pid',
+ expect.stringContaining('launch-delayed'),
+ { mode: 0o600, flag: 'wx' }
+ )
+ expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled()
+ expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce()
+
+ child.exitCode = 0
+ for (const callback of handlers.exit.slice()) {
+ callback(0)
+ }
+ expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
+ '/fake/daemon.pid',
+ 12345,
+ 'launch-delayed'
+ )
+ })
+
+ it('kills and rejects a daemon whose readiness message omits its start time', async () => {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+ const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
+ throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
+ })
+ const child = {
+ pid: 12345,
+ on(event: string, cb: (arg?: unknown) => void) {
+ if (event === 'message') {
+ queueMicrotask(() => cb({ type: 'ready' }))
+ }
+ return this
+ },
+ off: vi.fn(),
+ disconnect: vi.fn(),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+
+ try {
+ await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
+ 'Daemon readiness identity is incomplete'
+ )
+ expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
+ expect(writeFileSyncMock).not.toHaveBeenCalled()
+ expect(child.disconnect).not.toHaveBeenCalled()
+ expect(child.unref).toHaveBeenCalledOnce()
+ } finally {
+ kill.mockRestore()
+ }
+ })
+
+ it('rejects startup cleanup when SIGKILL never produces child exit', async () => {
+ vi.useFakeTimers()
+ const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
+ try {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null,
+ signalCode: null,
+ on(event: string, callback: (arg?: unknown) => void) {
+ handlers[event]?.push(callback)
+ if (event === 'message') {
+ queueMicrotask(() => callback({ type: 'ready' }))
+ }
+ return this
+ },
+ off(event: string, callback: (arg?: unknown) => void) {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
+ return this
+ },
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+
+ const launch = launcher('/fake/socket', '/fake/token')
+ await Promise.resolve()
+ await Promise.resolve()
+ const rejection = expect(launch).rejects.toThrow('startup and child cleanup both failed')
+ await vi.advanceTimersByTimeAsync(6_000)
+ await rejection
+
+ expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM')
+ expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
+ expect(child.disconnect).toHaveBeenCalledOnce()
+ expect(child.unref).toHaveBeenCalledOnce()
+ } finally {
+ kill.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ it('surfaces non-ESRCH startup termination errors and releases IPC', async () => {
+ const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' })
+ const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
+ throw signalError
+ })
+ try {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null,
+ signalCode: null,
+ on(event: string, callback: (arg?: unknown) => void) {
+ if (event === 'message') {
+ queueMicrotask(() => callback({ type: 'ready' }))
+ }
+ return this
+ },
+ off: vi.fn(),
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+
+ const error = await launcher('/fake/socket', '/fake/token').catch((caught: unknown) => caught)
+
+ expect(error).toBeInstanceOf(AggregateError)
+ expect((error as AggregateError).errors).toEqual([
+ expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
+ signalError
+ ])
+ expect(child.disconnect).toHaveBeenCalledOnce()
+ expect(child.unref).toHaveBeenCalledOnce()
+ } finally {
+ kill.mockRestore()
+ }
+ })
+
+ it('settles startup with both errors when a malformed-ready child ignores termination', async () => {
+ vi.useFakeTimers()
+ const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
+ try {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null,
+ signalCode: null,
+ on(event: string, callback: (arg?: unknown) => void) {
+ handlers[event]?.push(callback)
+ if (event === 'message') {
+ queueMicrotask(() => callback({ type: 'ready' }))
+ }
+ return this
+ },
+ off(event: string, callback: (arg?: unknown) => void) {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
+ return this
+ },
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+
+ const launch = launcher('/fake/socket', '/fake/token').catch((error: unknown) => error)
+ await vi.advanceTimersByTimeAsync(6_000)
+ const error = await launch
+
+ expect(error).toBeInstanceOf(AggregateError)
+ expect((error as AggregateError).errors).toEqual([
+ expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
+ expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' })
+ ])
+ expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM')
+ expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
+ expect(handlers.message).toHaveLength(0)
+ expect(handlers.error).toHaveLength(0)
+ expect(handlers.exit).toHaveLength(0)
+ expect(child.disconnect).toHaveBeenCalledOnce()
+ expect(child.unref).toHaveBeenCalledOnce()
+ } finally {
+ kill.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ it('kills and rejects a daemon when exclusive PID publication fails', async () => {
+ const mod = await importFresh()
+ checkDaemonHealthMock.mockResolvedValue('unreachable')
+ await mod.initDaemonPtyProvider()
+
+ const launcher = spawnerInstances[0].launcher as (
+ socketPath: string,
+ tokenPath: string
+ ) => Promise<{ shutdown(): Promise }>
+ const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
+ throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
+ })
+ const child = {
+ pid: 12345,
+ on(event: string, cb: (arg?: unknown) => void) {
+ if (event === 'message') {
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
+ }
+ return this
+ },
+ off: vi.fn(),
+ disconnect: vi.fn(),
+ unref: vi.fn()
+ }
+ const publicationError = Object.assign(new Error('PID record already exists'), {
+ code: 'EEXIST'
+ })
+ writeFileSyncMock.mockImplementationOnce(() => {
+ throw publicationError
+ })
+ forkMock.mockReturnValueOnce(child)
+
+ try {
+ await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError)
+ expect(writeFileSyncMock).toHaveBeenCalledWith(
+ `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
+ expect.any(String),
+ { mode: 0o600, flag: 'wx' }
+ )
+ expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
+ expect(child.disconnect).not.toHaveBeenCalled()
+ expect(child.unref).toHaveBeenCalledOnce()
+ } finally {
+ kill.mockRestore()
+ }
})
it('removes detached daemon startup listeners after startup error', async () => {
@@ -1390,7 +2033,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(handlers.error).toHaveLength(0)
expect(handlers.exit).toHaveLength(0)
expect(child.disconnect).not.toHaveBeenCalled()
- expect(child.unref).not.toHaveBeenCalled()
+ expect(child.unref).toHaveBeenCalledOnce()
})
it('captures daemon startup stderr into the failure error', async () => {
@@ -1429,6 +2072,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
}
const child = {
pid: 4321,
+ exitCode: null as number | null,
stderr,
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
@@ -1439,6 +2083,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
for (const dataCb of stderrDataCbs.slice()) {
dataCb(Buffer.from("Error: Cannot find module 'electron'\n"))
}
+ child.exitCode = 1
cb(1)
})
}
@@ -1492,10 +2137,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
+ once(event: string, cb: (arg?: unknown) => void) {
+ handlers[event]?.push(cb)
+ return this
+ },
off: vi.fn(() => child),
disconnect: vi.fn(),
unref: vi.fn()
@@ -1523,6 +2172,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1557,6 +2207,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
}
return {}
})
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
@@ -1602,10 +2253,13 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
+ once() {
+ return this
+ },
off() {
return this
},
@@ -1700,11 +2354,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
disconnect: vi.fn()
}
}
- // Every probe across the whole grace window times out (permanent wedge).
+ // Every probe across the whole grace window times out (permanent wedge),
+ // then the freshly spawned daemon accepts the temporary adoption lease.
+ let daemonClientConstructionCount = 0
daemonClientMock.mockImplementation(function MockDaemonClient() {
+ daemonClientConstructionCount++
return {
ensureConnected: vi.fn(async () => {
- throw new Error('Hello response timed out')
+ if (daemonClientConstructionCount <= 2 + WEDGED_DAEMON_GRACE_RETRIES) {
+ throw new Error('Hello response timed out')
+ }
}),
request: vi.fn(),
disconnect: vi.fn()
@@ -1722,7 +2381,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1747,7 +2406,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(forkMock).toHaveBeenCalled()
// The launcher probes the full grace budget before giving up: 1 initial
// probe + WEDGED_DAEMON_GRACE_RETRIES retries.
- expect(daemonClientMock).toHaveBeenCalledTimes(1 + WEDGED_DAEMON_GRACE_RETRIES)
+ expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES)
} finally {
// Restore the answering default so the persistent throwing impl above
// does not leak into later tests (clearAllMocks clears calls, not impls).
@@ -1842,7 +2501,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -1864,8 +2523,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(forkMock).toHaveBeenCalled()
// Pins the 'rejected' fast-path: a daemon that actively refuses the
// handshake is never worth a grace window, so it is probed exactly once
- // (no retries) before replacement.
- expect(daemonClientMock).toHaveBeenCalledTimes(1)
+ // (no retries) before replacement. The other clients are the initial
+ // adoption attempt and the fresh daemon's temporary adoption lease.
+ expect(daemonClientMock).toHaveBeenCalledTimes(3)
})
it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => {
@@ -1917,16 +2577,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
await launcher('/fake/socket', '/fake/token')
- expect(writeFileSyncMock).toHaveBeenCalledWith(
- `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
- JSON.stringify({
- pid: 12345,
- startedAtMs: 1_700_000_123_456,
- entryPath: FAKE_DAEMON_ENTRY_PATH,
- appVersion: '1.2.3'
- }),
- { mode: 0o600 }
- )
+ const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? []
+ expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`)
+ expect(JSON.parse(pidContents as string)).toEqual({
+ pid: 12345,
+ startedAtMs: 1_700_000_123_456,
+ entryPath: FAKE_DAEMON_ENTRY_PATH,
+ appVersion: '1.2.3',
+ launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/)
+ })
+ expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' })
})
it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => {
@@ -1987,7 +2647,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -2073,7 +2733,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
- queueMicrotask(() => cb({ type: 'ready' }))
+ queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
@@ -2126,6 +2786,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
return {}
})
const disconnectMock = vi.fn()
+ mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts
index 1bbc2cad9c3..093cb7a00f7 100644
--- a/src/main/daemon/daemon-init.ts
+++ b/src/main/daemon/daemon-init.ts
@@ -7,9 +7,10 @@ files with no cleaner ownership seam: restart, replaceDaemonProvider, and the
module-level spawner/adapter singletons must stay co-located so a future
change cannot leave them drifting out of sync. */
import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
import { app } from 'electron'
import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
-import { fork } from 'node:child_process'
+import { fork, type ChildProcess } from 'node:child_process'
import { connect } from 'node:net'
import {
DaemonSpawner,
@@ -17,6 +18,7 @@ import {
getDaemonSocketPath,
getDaemonTokenPath,
serializeDaemonPidFile,
+ unlinkOwnedDaemonPidFile,
type DaemonLauncher,
type DaemonProcessHandle
} from './daemon-spawner'
@@ -24,6 +26,7 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonClient } from './client'
import {
+ CLEAN_DISCONNECT_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
type ListSessionsResult
@@ -31,7 +34,6 @@ import {
import {
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
- getProcessStartedAtMs,
checkDaemonHealth,
isDaemonStaleForCurrentBundle,
killStaleDaemon,
@@ -82,6 +84,9 @@ function logDaemonMilestone(event: string, details: Record = {}
// that takes longer than ~60s to drain is replaced (live processes lost, though
// scrollback cold-restores). Raise this only alongside the fail-open cap.
export const WEDGED_DAEMON_GRACE_RETRIES = 11
+const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000
+const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
+const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000
let spawner: DaemonSpawner | null = null
type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
@@ -205,6 +210,134 @@ function createPreservedDaemonHandle(
return handle
}
+async function holdDaemonAdoptionLease(
+ handle: DaemonProcessHandle,
+ socketPath: string,
+ tokenPath: string,
+ connectedClient?: DaemonClient
+): Promise {
+ const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath })
+ try {
+ await client.ensureConnected()
+ } catch (error) {
+ client.disconnect()
+ throw error
+ }
+ handle.releaseAdoptionLease = () => client.disconnect()
+ return handle
+}
+
+function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void {
+ takeDaemonAdoptionLeaseRelease(handle)?.()
+}
+
+function takeDaemonAdoptionLeaseRelease(
+ handle: DaemonProcessHandle | null
+): (() => void) | undefined {
+ const release = handle?.releaseAdoptionLease
+ if (!release || !handle) {
+ return undefined
+ }
+ delete handle.releaseAdoptionLease
+ return release
+}
+
+async function cleanupFailedDaemonAdoption(
+ failedSpawner: DaemonSpawner,
+ current: DaemonPtyAdapter,
+ legacy: DaemonPtyAdapter[] = []
+): Promise {
+ const handle = failedSpawner.getHandle()
+ const results = await Promise.allSettled([
+ Promise.resolve().then(() => releaseDaemonAdoptionLease(handle)),
+ ...legacy.map((entry) => entry.disconnectOnly()),
+ (async () => {
+ try {
+ // Why: endpoint publication allows other authenticated clients to win;
+ // only daemon-side shutdownIfIdle may prove a failed adoption is killable.
+ await current.disconnectOnly()
+ } catch (error) {
+ current.dispose()
+ throw error
+ }
+ })()
+ ])
+ const failures = results.flatMap((result) =>
+ result.status === 'rejected' ? [result.reason] : []
+ )
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'Daemon adoption cleanup failed')
+ }
+}
+
+async function terminateLaunchedDaemonChild(child: ChildProcess): Promise {
+ try {
+ if (
+ (child.exitCode !== null && child.exitCode !== undefined) ||
+ (child.signalCode !== null && child.signalCode !== undefined)
+ ) {
+ return
+ }
+ await new Promise((resolve, reject) => {
+ let gracefulTimer: ReturnType
+ let forcedTimer: ReturnType | undefined
+ let settled = false
+ const finish = (error?: unknown): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(gracefulTimer)
+ if (forcedTimer) {
+ clearTimeout(forcedTimer)
+ }
+ child.off('exit', onExit)
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ }
+ const onExit = (): void => finish()
+ child.on('exit', onExit)
+ gracefulTimer = setTimeout(() => {
+ if (child.pid) {
+ try {
+ process.kill(child.pid, 'SIGKILL')
+ } catch (error) {
+ finish(isNoSuchProcessError(error) ? undefined : error)
+ return
+ }
+ }
+ if (!settled) {
+ forcedTimer = setTimeout(
+ () => finish(new Error('Daemon did not exit after SIGKILL')),
+ DAEMON_CHILD_FORCE_EXIT_WAIT_MS
+ )
+ }
+ }, DAEMON_CHILD_TERMINATION_GRACE_MS)
+ if (child.pid) {
+ try {
+ process.kill(child.pid, 'SIGTERM')
+ } catch (error) {
+ finish(isNoSuchProcessError(error) ? undefined : error)
+ }
+ } else {
+ finish()
+ }
+ })
+ } finally {
+ if (child.connected) {
+ child.disconnect()
+ }
+ child.unref()
+ }
+}
+
+function isNoSuchProcessError(error: unknown): boolean {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
+}
+
async function shouldPreserveDaemonWithLiveSessions(
socketPath: string,
tokenPath: string,
@@ -223,275 +356,351 @@ async function shouldPreserveDaemonWithLiveSessions(
}
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
- return async (socketPath, tokenPath) => {
+ return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
const entryPath = getDaemonEntryPath()
- const health = await checkDaemonHealth(socketPath, tokenPath)
- if (health === 'healthy') {
- const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
- if (resolverHealth === 'unhealthy') {
- const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
- if (liveSessionCount !== 0) {
- console.warn(
- liveSessionCount === null
- ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
- : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
- )
- return createPreservedDaemonHandle(runtimeDir)
- }
- console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
- await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
- } else {
- // Why: a protocol-healthy daemon can outlive the app bundle that
- // launched it. In dev this happens after deleting/rebuilding a
- // worktree; in packaged apps it happens when the stable
- // /Applications/Orca.app path is replaced during update.
- const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
- const stalePackagedBundle =
- app.isPackaged &&
- (await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()))
- if (identity === 'mismatch' || stalePackagedBundle) {
- // Why: replacing a healthy daemon kills its child PTYs; defer code
- // freshness until no live terminal sessions would be lost.
- const replacementLabel = stalePackagedBundle
- ? 'launched before the current app bundle was installed'
- : 'launched from a different app path'
- if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
- return createPreservedDaemonHandle(runtimeDir)
+ const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
+ const launchNonce = suppliedLaunchNonce ?? randomUUID()
+ let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath })
+ try {
+ // Why: acquire the full pair before any control-only probes so an expired
+ // inherited deadline cannot fire in the probe-to-adoption gap.
+ await adoptionClient.ensureConnected()
+ } catch {
+ adoptionClient.disconnect()
+ adoptionClient = null
+ }
+ const preserveDaemon = async (
+ mode?: 'degraded-new-pty-fallback'
+ ): Promise => {
+ const connectedClient = adoptionClient ?? undefined
+ adoptionClient = null
+ return holdDaemonAdoptionLease(
+ createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode),
+ socketPath,
+ tokenPath,
+ connectedClient
+ )
+ }
+ try {
+ const health = await checkDaemonHealth(socketPath, tokenPath)
+ if (health === 'healthy') {
+ const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
+ if (resolverHealth === 'unhealthy') {
+ const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
+ if (liveSessionCount !== 0) {
+ console.warn(
+ liveSessionCount === null
+ ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
+ : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
+ )
+ return preserveDaemon()
}
- console.warn(
- stalePackagedBundle
- ? '[daemon] Replacing daemon launched before the current app bundle was installed'
- : '[daemon] Replacing daemon launched from a different app path'
- )
+ console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
- // Why: daemon is already running from a previous app session and
- // responded to a protocol-level ping. Safe to reuse.
- return createPreservedDaemonHandle(runtimeDir)
- }
- }
- } else {
- // Why: a busy machine (e.g. right after an update) can time out the
- // health check while the daemon is alive and owning terminals. Killing
- // it would destroy every live session, so re-verify with a session list
- // first.
- let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
- // Why: on a Windows update relaunch the daemon can be transiently wedged
- // past every RPC budget (final checkpoint flush + installer/AV disk
- // pressure) while its sessions are still alive — replacing it here is what
- // killed those sessions. A pipe that still accepts connections proves a
- // live daemon, so give a wedged-but-connectable daemon a bounded grace to
- // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts
- // connections but its event loop never answers hello — #8689) exhausts the
- // grace and falls through to replacement below, instead of being preserved
- // forever, which strands the app with zero working terminals. 'rejected'
- // means the daemon answered and refused the handshake — it can never be
- // adopted, so it skips the grace and replacement stays the only recovery.
- let graceRetry = 0
- while (
- liveSessionCount === null &&
- health !== 'rejected' &&
- graceRetry < WEDGED_DAEMON_GRACE_RETRIES &&
- (await probeSocket(socketPath))
- ) {
- liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
- graceRetry++
- }
- if (liveSessionCount !== null && liveSessionCount > 0) {
- if (health === 'pty-spawn-unhealthy') {
- console.warn(
- `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).`
- )
- return createPreservedDaemonHandle(
+ // Why: a protocol-healthy daemon can outlive the app bundle that
+ // launched it. In dev this happens after deleting/rebuilding a
+ // worktree; in packaged apps it happens when the stable
+ // /Applications/Orca.app path is replaced during update.
+ const identity = await getDaemonLaunchIdentity(
runtimeDir,
- PROTOCOL_VERSION,
- 'degraded-new-pty-fallback'
+ socketPath,
+ tokenPath,
+ entryPath
)
- }
- console.warn(
- `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
- )
- return createPreservedDaemonHandle(runtimeDir)
- }
- }
-
- // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
- // before respawn so the new daemon does not race the stale process.
- await killStaleDaemon(runtimeDir, socketPath, tokenPath)
-
- const userDataPath = app.getPath('userData')
- // Why: on win32 packaged, fork from a copy of the Electron runtime staged
- // in userData so the daemon's image + loaded modules escape the install dir
- // the NSIS updater deletes and force-closes. Staged here (not at app start)
- // so the one-time copy stays off the first-paint path and is skipped on
- // launches that adopt a live daemon. Fail-open: null → in-dir host, below.
- const relocatedHost = materializeRelocatedDaemonHost()
- // Fork the relocated entry when available; otherwise the install-dir entry.
- const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath
- const child = fork(
- forkEntryPath,
- ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()],
- {
- // Why: detached daemons can outlive dev worktrees. Starting from
- // userData keeps process.cwd() valid after a repo/worktree is deleted.
- cwd: userDataPath,
- // Why: detached + unref lets the daemon outlive the Electron process.
- // stdout stays 'ignore' so the child never holds the parent's stdout
- // open (which would block Electron exit); stderr is 'pipe' so a
- // module-load crash during startup is captured instead of discarded
- // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1"
- // because stderr was thrown away). The pipe is destroyed on readiness.
- detached: true,
- stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
- // Why: run the relocated Orca.exe copy instead of the install-dir one.
- // It is byte-identical, so run-as-node behavior is unchanged; only the
- // image path moves out of the updater's kill zone.
- ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}),
- // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
- // Node.js process instead of an Electron renderer/main process. Without
- // it, Electron's GPU/display initialization can interfere with native
- // module operations like node-pty's posix_spawn of the spawn-helper.
- env: {
- ...process.env,
- ELECTRON_RUN_AS_NODE: '1',
- // Why: the detached daemon is plain Node and cannot call Electron's
- // app.getPath(), but shell-ready rcfiles must live outside swept tmp.
- ORCA_USER_DATA_PATH: userDataPath
- }
- }
- )
-
- // Why: keep only the startup-window stderr tail so a crash cause is
- // visible without unbounded memory if the daemon spews before dying.
- const STARTUP_STDERR_MAX_BYTES = 8192
- let startupStderr = ''
- let collectingStderr = true
- const onStartupStderr = (chunk: Buffer): void => {
- if (!collectingStderr) {
- return
- }
- startupStderr += chunk.toString('utf8')
- if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) {
- startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES)
- }
- }
- child.stderr?.on('data', onStartupStderr)
- // Why: once the daemon is up (or has failed) the parent must not keep a
- // live handle on the detached daemon's stderr — a piped stream would ref
- // the parent event loop and prevent Electron from exiting cleanly.
- const releaseStderr = (): void => {
- collectingStderr = false
- child.stderr?.off('data', onStartupStderr)
- child.stderr?.destroy()
- }
-
- // Wait for the daemon to signal readiness via IPC
- await new Promise((resolve, reject) => {
- let timer: ReturnType | undefined
- let settled = false
- function cleanupStartupListeners(): void {
- if (timer) {
- clearTimeout(timer)
- }
- child.off('message', onReadyMessage)
- child.off('error', onStartupError)
- child.off('exit', onStartupExit)
- }
- function fail(error: Error): void {
- if (settled) {
- return
- }
- settled = true
- cleanupStartupListeners()
- // Why: stderr was previously discarded, so a startup crash surfaced only
- // as "exited with code 1". Attach the captured tail to the thrown error
- // (which the fallback path reports) and log it so the real cause shows.
- const stderrTail = startupStderr.trim()
- if (stderrTail) {
- console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`)
- }
- releaseStderr()
- if (child.pid) {
- try {
- process.kill(child.pid, 'SIGTERM')
- } catch {
- // Already dead
+ const stalePackagedBundle =
+ app.isPackaged &&
+ (await isDaemonStaleForCurrentBundle(
+ runtimeDir,
+ socketPath,
+ tokenPath,
+ app.getVersion()
+ ))
+ if (identity === 'mismatch' || stalePackagedBundle) {
+ // Why: replacing a healthy daemon kills its child PTYs; defer code
+ // freshness until no live terminal sessions would be lost.
+ const replacementLabel = stalePackagedBundle
+ ? 'launched before the current app bundle was installed'
+ : 'launched from a different app path'
+ if (
+ await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)
+ ) {
+ return preserveDaemon()
+ }
+ console.warn(
+ stalePackagedBundle
+ ? '[daemon] Replacing daemon launched before the current app bundle was installed'
+ : '[daemon] Replacing daemon launched from a different app path'
+ )
+ await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
+ } else {
+ // Why: daemon is already running from a previous app session and
+ // responded to a protocol-level ping. Safe to reuse.
+ return preserveDaemon()
}
}
- reject(
- stderrTail ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) : error
- )
+ } else {
+ // Why: a busy machine (e.g. right after an update) can time out the
+ // health check while the daemon is alive and owning terminals. Killing
+ // it would destroy every live session, so re-verify with a session list
+ // first.
+ let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
+ // Why: on a Windows update relaunch the daemon can be transiently wedged
+ // past every RPC budget (final checkpoint flush + installer/AV disk
+ // pressure) while its sessions are still alive — replacing it here is what
+ // killed those sessions. A pipe that still accepts connections proves a
+ // live daemon, so give a wedged-but-connectable daemon a bounded grace to
+ // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts
+ // connections but its event loop never answers hello — #8689) exhausts the
+ // grace and falls through to replacement below, instead of being preserved
+ // forever, which strands the app with zero working terminals. 'rejected'
+ // means the daemon answered and refused the handshake — it can never be
+ // adopted, so it skips the grace and replacement stays the only recovery.
+ let graceRetry = 0
+ while (
+ liveSessionCount === null &&
+ health !== 'rejected' &&
+ graceRetry < WEDGED_DAEMON_GRACE_RETRIES &&
+ (await probeSocket(socketPath))
+ ) {
+ liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
+ graceRetry++
+ }
+ if (liveSessionCount !== null && liveSessionCount > 0) {
+ if (health === 'pty-spawn-unhealthy') {
+ console.warn(
+ `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).`
+ )
+ return preserveDaemon('degraded-new-pty-fallback')
+ }
+ console.warn(
+ `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
+ )
+ return preserveDaemon()
+ }
}
- function onReadyMessage(msg: unknown): void {
- if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
+
+ // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
+ // before respawn so the new daemon does not race the stale process.
+ adoptionClient?.disconnect()
+ adoptionClient = null
+ await killStaleDaemon(runtimeDir, socketPath, tokenPath)
+
+ const userDataPath = app.getPath('userData')
+ // Why: on win32 packaged, fork from a copy of the Electron runtime staged
+ // in userData so the daemon's image + loaded modules escape the install dir
+ // the NSIS updater deletes and force-closes. Staged here (not at app start)
+ // so the one-time copy stays off the first-paint path and is skipped on
+ // launches that adopt a live daemon. Fail-open: null → in-dir host, below.
+ const relocatedHost = materializeRelocatedDaemonHost()
+ // Fork the relocated entry when available; otherwise the install-dir entry.
+ const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath
+ const child = fork(
+ forkEntryPath,
+ [
+ '--socket',
+ socketPath,
+ '--token',
+ tokenPath,
+ '--pid-record',
+ pidPath,
+ '--launch-nonce',
+ launchNonce,
+ ...daemonLogArgs()
+ ],
+ {
+ // Why: detached daemons can outlive dev worktrees. Starting from
+ // userData keeps process.cwd() valid after a repo/worktree is deleted.
+ cwd: userDataPath,
+ // Why: detached + unref lets the daemon outlive the Electron process.
+ // stdout stays 'ignore' so the child never holds the parent's stdout
+ // open (which would block Electron exit); stderr is 'pipe' so a
+ // module-load crash during startup is captured instead of discarded
+ // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1"
+ // because stderr was thrown away). The pipe is destroyed on readiness.
+ detached: true,
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
+ // Why: run the relocated Orca.exe copy instead of the install-dir one.
+ // It is byte-identical, so run-as-node behavior is unchanged; only the
+ // image path moves out of the updater's kill zone.
+ ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}),
+ // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
+ // Node.js process instead of an Electron renderer/main process. Without
+ // it, Electron's GPU/display initialization can interfere with native
+ // module operations like node-pty's posix_spawn of the spawn-helper.
+ env: {
+ ...process.env,
+ ELECTRON_RUN_AS_NODE: '1',
+ // Why: the detached daemon is plain Node and cannot call Electron's
+ // app.getPath(), but shell-ready rcfiles must live outside swept tmp.
+ ORCA_USER_DATA_PATH: userDataPath
+ }
+ }
+ )
+
+ // Why: keep only the startup-window stderr tail so a crash cause is
+ // visible without unbounded memory if the daemon spews before dying.
+ const STARTUP_STDERR_MAX_BYTES = 8192
+ let startupStderr = ''
+ let collectingStderr = true
+ const onStartupStderr = (chunk: Buffer): void => {
+ if (!collectingStderr) {
+ return
+ }
+ startupStderr += chunk.toString('utf8')
+ if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) {
+ startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES)
+ }
+ }
+ child.stderr?.on('data', onStartupStderr)
+ // Why: once the daemon is up (or has failed) the parent must not keep a
+ // live handle on the detached daemon's stderr — a piped stream would ref
+ // the parent event loop and prevent Electron from exiting cleanly.
+ const releaseStderr = (): void => {
+ collectingStderr = false
+ child.stderr?.off('data', onStartupStderr)
+ child.stderr?.destroy()
+ }
+
+ // Wait for the daemon to signal readiness via IPC
+ await new Promise((resolve, reject) => {
+ let timer: ReturnType | undefined
+ let settled = false
+ function cleanupStartupListeners(): void {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ child.off('message', onReadyMessage)
+ child.off('error', onStartupError)
+ child.off('exit', onStartupExit)
+ }
+ async function fail(error: Error): Promise {
if (settled) {
return
}
settled = true
- // Why: the daemon process is detached after readiness; leaving
- // startup listeners attached retains this launch promise closure.
cleanupStartupListeners()
- if (child.pid) {
- // Why: JSON pid file carries pid + process start time so later
- // killStaleDaemon() can verify the pid still belongs to the daemon
- // we forked before SIGTERMing it. Prevents pid-recycling hazard
- // where the OS hands the daemon's old pid to an unrelated process.
- // Why the ready-message fallback: Windows has no cheap OS query
- // for start time, so the daemon self-reports it — without this the
- // recycling guard was permanently inert on win32.
- const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
- writeFileSync(
- getDaemonPidPath(runtimeDir),
- serializeDaemonPidFile({
- pid: child.pid,
- startedAtMs:
- getProcessStartedAtMs(child.pid) ??
- (typeof selfReported === 'number' && Number.isFinite(selfReported)
- ? selfReported
- : null),
- entryPath,
- appVersion: app.getVersion()
- }),
- { mode: 0o600 }
- )
+ // Why: stderr was previously discarded, so a startup crash surfaced only
+ // as "exited with code 1". Attach the captured tail to the thrown error
+ // (which the fallback path reports) and log it so the real cause shows.
+ const stderrTail = startupStderr.trim()
+ if (stderrTail) {
+ console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`)
}
- // Why: disconnect IPC channel, release the stderr pipe, and unref so
- // Electron can exit without waiting for the daemon. The daemon keeps
- // running detached.
releaseStderr()
- child.disconnect()
- child.unref()
- resolve()
- }
- }
-
- function onStartupError(err: Error): void {
- fail(err)
- }
-
- function onStartupExit(code: number | null): void {
- fail(new Error(`Daemon exited during startup with code ${code}`))
- }
-
- timer = setTimeout(() => {
- fail(new Error('Daemon startup timed out'))
- }, 10000)
-
- child.on('message', onReadyMessage)
- child.on('error', onStartupError)
- child.on('exit', onStartupExit)
- })
-
- return {
- shutdown: async () => {
- if (child.pid) {
+ const startupError = stderrTail
+ ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`)
+ : error
try {
- process.kill(child.pid, 'SIGTERM')
- } catch {
- // Already dead
+ await terminateLaunchedDaemonChild(child)
+ } catch (cleanupError) {
+ reject(
+ new AggregateError(
+ [startupError, cleanupError],
+ 'Daemon startup and child cleanup both failed'
+ )
+ )
+ return
+ }
+ reject(startupError)
+ }
+ function onReadyMessage(msg: unknown): void {
+ if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
+ if (settled) {
+ return
+ }
+ const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
+ if (
+ !Number.isSafeInteger(child.pid) ||
+ (child.pid as number) <= 0 ||
+ typeof selfReported !== 'number' ||
+ !Number.isFinite(selfReported) ||
+ selfReported <= 0
+ ) {
+ void fail(new Error('Daemon readiness identity is incomplete'))
+ return
+ }
+ try {
+ // Why: hello and the PID record must share the daemon's self time
+ // and nonce so cleanup can identify this exact process incarnation.
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({
+ pid: child.pid as number,
+ startedAtMs: selfReported,
+ entryPath,
+ appVersion: app.getVersion(),
+ launchNonce
+ }),
+ { mode: 0o600, flag: 'wx' }
+ )
+ } catch (error) {
+ void fail(error instanceof Error ? error : new Error(String(error)))
+ return
+ }
+ settled = true
+ // Why: the daemon process is detached after readiness; leaving
+ // startup listeners attached retains this launch promise closure.
+ cleanupStartupListeners()
+ // Why: disconnect IPC channel, release the stderr pipe, and unref so
+ // Electron can exit without waiting for the daemon. The daemon keeps
+ // running detached.
+ releaseStderr()
+ child.disconnect()
+ child.unref()
+ resolve()
}
}
+
+ function onStartupError(err: Error): void {
+ void fail(err)
+ }
+
+ function onStartupExit(code: number | null): void {
+ void fail(new Error(`Daemon exited during startup with code ${code}`))
+ }
+
+ timer = setTimeout(() => {
+ void fail(new Error('Daemon startup timed out'))
+ }, 10000)
+
+ child.on('message', onReadyMessage)
+ child.on('error', onStartupError)
+ child.on('exit', onStartupExit)
+ })
+
+ try {
+ return await holdDaemonAdoptionLease(
+ {
+ shutdown: () => terminateLaunchedDaemonChild(child)
+ },
+ socketPath,
+ tokenPath
+ )
+ } catch (error) {
+ // Why: another client may have adopted this still-live process. Keep its
+ // valid identity until exit, but remove a record published after an early exit.
+ let pidRecordRemoved = false
+ const removeExitedPidRecord = (): void => {
+ if (pidRecordRemoved) {
+ return
+ }
+ pidRecordRemoved = true
+ unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce)
+ }
+ child.once('exit', removeExitedPidRecord)
+ if (
+ (child.exitCode !== null && child.exitCode !== undefined) ||
+ (child.signalCode !== null && child.signalCode !== undefined)
+ ) {
+ child.off('exit', removeExitedPidRecord)
+ removeExitedPidRecord()
+ }
+ throw error
}
+ } catch (error) {
+ adoptionClient?.disconnect()
+ throw error
}
}
}
@@ -525,7 +734,14 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise
logDaemonMilestone('daemon-current-ready')
if (signal?.aborted) {
// Why: startup fail-open may already have allowed fallback LocalPtyProvider
- // PTYs to spawn. A late daemon swap would strand those PTYs on the old owner.
+ // PTYs to spawn. Do not install late, but give an empty daemon one bounded
+ // authenticated retirement attempt; live adopted sessions reject it and survive.
+ const abortedStartupAdapter = new DaemonPtyAdapter({
+ socketPath: info.socketPath,
+ tokenPath: info.tokenPath
+ })
+ releaseDaemonAdoptionLease(newSpawner.getHandle())
+ await abortedStartupAdapter.disconnectOnly()
return
}
@@ -541,37 +757,54 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise
console.warn('[daemon] Daemon process died — respawning')
newSpawner.resetHandle()
await newSpawner.ensureRunning()
+ return takeDaemonAdoptionLeaseRelease(newSpawner.getHandle())
}
})
+ let legacyAdapters: DaemonPtyAdapter[] = []
+ let routedAdapter: DaemonProvider = newAdapter
+ try {
+ // Why: the launcher's temporary pair closes only after this permanent
+ // adapter pair is established, leaving no retirement gap during adoption.
+ await newAdapter.establishLifecycleLease()
+ releaseDaemonAdoptionLease(newSpawner.getHandle())
- const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
- const routedAdapter =
- launchMode === 'degraded-new-pty-fallback'
- ? new DegradedDaemonPtyProvider({
- current: newAdapter,
- legacy: legacyAdapters,
- fallback: getLocalPtyProvider()
- })
- : legacyAdapters.length > 0
- ? new DaemonPtyRouter({
+ legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
+ routedAdapter =
+ launchMode === 'degraded-new-pty-fallback'
+ ? new DegradedDaemonPtyProvider({
current: newAdapter,
- legacy: legacyAdapters
+ legacy: legacyAdapters,
+ fallback: getLocalPtyProvider()
})
- : newAdapter
- if (routedAdapter instanceof DegradedDaemonPtyProvider) {
- // Why: the preserved daemon cannot create fresh terminals, but its live
- // sessions may still be writable. Discover those ids so only known old
- // sessions route to the degraded daemon; fresh panes fall back locally.
- await routedAdapter.discoverDaemonSessions()
- } else if (routedAdapter instanceof DaemonPtyRouter) {
- await routedAdapter.discoverLegacySessions()
+ : legacyAdapters.length > 0
+ ? new DaemonPtyRouter({
+ current: newAdapter,
+ legacy: legacyAdapters
+ })
+ : newAdapter
+ if (routedAdapter instanceof DegradedDaemonPtyProvider) {
+ // Why: the preserved daemon cannot create fresh terminals, but its live
+ // sessions may still be writable. Discover those ids so only known old
+ // sessions route to the degraded daemon; fresh panes fall back locally.
+ await routedAdapter.discoverDaemonSessions()
+ } else if (routedAdapter instanceof DaemonPtyRouter) {
+ await routedAdapter.discoverLegacySessions()
+ }
+ if (signal?.aborted) {
+ // Why: same late-swap guard after legacy discovery, which can also exceed
+ // the first-window startup timeout on slow or stale daemon state. Release
+ // every uninstalled adapter lease without killing its live sessions.
+ await routedAdapter.disconnectOnly()
+ return
+ }
+ } catch (error) {
+ try {
+ await cleanupFailedDaemonAdoption(newSpawner, newAdapter, legacyAdapters)
+ } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'Daemon adoption and cleanup both failed')
+ }
+ throw error
}
- if (signal?.aborted) {
- // Why: same late-swap guard after legacy discovery, which can also exceed
- // the first-window startup timeout on slow or stale daemon state.
- return
- }
-
spawner = newSpawner
adapter = routedAdapter
setLocalPtyProvider(routedAdapter)
@@ -713,12 +946,20 @@ async function runRestartDaemon(): Promise {
// Step 3: kill the current-protocol daemon process (shutdown RPC → fallback
// killStaleDaemon → socket/pid unlink). Legacy adapters untouched.
- await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
+ let info: Awaited>
+ try {
+ await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
- // Step 4: reuse the existing spawner so the respawn closure baked into
- // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner.
- currentSpawner.resetHandle()
- const info = await currentSpawner.ensureRunning()
+ // Step 4: reuse the existing spawner so the respawn closure baked into
+ // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner.
+ currentSpawner.resetHandle()
+ info = await currentSpawner.ensureRunning()
+ } catch (error) {
+ // Why: the old provider remains authoritative until the final swap, even
+ // if cleanup or replacement launch fails after listener teardown.
+ rebindLocalProviderListeners()
+ throw error
+ }
// Step 5: build a fresh current adapter against the respawned daemon. Its
// respawn callback closes over the same spawner instance (identical to the
@@ -731,18 +972,43 @@ async function runRestartDaemon(): Promise {
console.warn('[daemon] Daemon process died — respawning')
currentSpawner.resetHandle()
await currentSpawner.ensureRunning()
+ return takeDaemonAdoptionLeaseRelease(currentSpawner.getHandle())
}
})
+ let newProvider: DaemonProvider = newCurrent
+ try {
+ // Why: the temporary launcher lease overlaps this permanent pair so manual
+ // restart cannot strand a newly spawned daemon during adoption.
+ await newCurrent.establishLifecycleLease()
+ releaseDaemonAdoptionLease(currentSpawner.getHandle())
- // Re-wrap in router if there were legacy adapters at startup; otherwise
- // point straight at the new adapter. Legacy instances are preserved by
- // reference — they still route to the same pre-upgrade daemons.
- const newProvider =
- legacyAdapters.length > 0
- ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters })
- : newCurrent
- if (newProvider instanceof DaemonPtyRouter) {
- await newProvider.discoverLegacySessions()
+ // Re-wrap in router if there were legacy adapters at startup; otherwise
+ // point straight at the new adapter. Legacy instances are preserved by
+ // reference — they still route to the same pre-upgrade daemons.
+ newProvider =
+ legacyAdapters.length > 0
+ ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters })
+ : newCurrent
+ if (newProvider instanceof DaemonPtyRouter) {
+ await newProvider.discoverLegacySessions()
+ }
+ } catch (error) {
+ let cleanupError: unknown
+ try {
+ if (newProvider instanceof DaemonPtyRouter) {
+ newProvider.disposeRouterOnly()
+ }
+ await cleanupFailedDaemonAdoption(currentSpawner, newCurrent)
+ } catch (caught) {
+ cleanupError = caught
+ }
+ // Why: the previous provider remains module-authoritative until the swap;
+ // restore its renderer bindings when replacement adoption fails.
+ rebindLocalProviderListeners()
+ if (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'Daemon restart and cleanup both failed')
+ }
+ throw error
}
// Why: drain the outgoing router's subscriptions from the shared legacy
@@ -777,11 +1043,6 @@ export async function shutdownDaemon(): Promise {
adapter = null
await spawner?.shutdown()
spawner = null
- try {
- unlinkSync(getDaemonPidPath(getRuntimeDir()))
- } catch {
- // Best-effort
- }
}
export type OrphanedDaemonCleanupResult = {
@@ -803,6 +1064,11 @@ export async function cleanupDaemonForProtocol(
const alive = await probeSocket(socketPath)
if (!alive) {
+ if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
+ // Why: endpoint absence does not prove a canonical PID record still
+ // belongs to the current protocol; the exact daemon owns its artifact cleanup.
+ return { cleaned: false, killedCount: 0 }
+ }
// Why: still best-effort remove a stale socket file so a future opt-in
// launch doesn't hit EADDRINUSE when the daemon tries to bind.
if (process.platform !== 'win32' && existsSync(socketPath)) {
@@ -848,6 +1114,15 @@ export async function cleanupDaemonForProtocol(
client.disconnect()
}
+ if (didRequestShutdown && protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
+ if (!(await waitForDaemonEndpointExit(socketPath))) {
+ // Why: never fork a replacement while the old incarnation may still
+ // own the endpoint or be disposing terminal children.
+ throw new Error('Timed out waiting for daemon self-shutdown')
+ }
+ return { cleaned: true, killedCount }
+ }
+
// Why: after `shutdown`, the daemon unlinks its socket itself — but on some
// crash paths the file lingers. Clean up defensively so a later opt-in
// relaunch can bind cleanly.
@@ -867,6 +1142,17 @@ export async function cleanupDaemonForProtocol(
return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount }
}
+async function waitForDaemonEndpointExit(socketPath: string): Promise {
+ const deadline = Date.now() + DAEMON_SELF_SHUTDOWN_WAIT_MS
+ while (Date.now() < deadline) {
+ if (!(await probeSocket(socketPath))) {
+ return true
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+ return !(await probeSocket(socketPath))
+}
+
function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean {
try {
const parsed = parseDaemonPidFile(
diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts
index 8d2d0a36c64..9884cdf0c80 100644
--- a/src/main/daemon/daemon-main.ts
+++ b/src/main/daemon/daemon-main.ts
@@ -4,9 +4,16 @@ import type { DaemonFileLog } from './daemon-file-log'
export type DaemonStartOptions = {
socketPath: string
tokenPath: string
+ pidPath?: string
+ launchNonce?: string
+ startedAtMs?: number
+ /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */
+ protocolVersion?: number
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
log?: DaemonFileLog
+ onIdleShutdown?: () => void
+ initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
}
export type DaemonHandle = {
@@ -17,9 +24,17 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise
+ respawn?: () => Promise void)>
}
const MAX_TOMBSTONES = 1000
@@ -92,7 +93,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
private client: DaemonClient
private historyManager: HistoryManager | null
private historyReader: HistoryReader | null
- private respawnFn: (() => Promise) | null
+ private respawnFn: (() => Promise void)>) | null
+ private pendingRespawnAdoptionRelease: (() => void) | null = null
+ private respawnAdoptionClosed = false
// Why: multiple pane mounts can call spawn() concurrently. If the daemon is
// dead, all calls enter withDaemonRetry's catch block at once. Without a
// lock, each would fork its own daemon process. This promise coalesces
@@ -958,6 +961,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
dispose(): void {
+ this.respawnAdoptionClosed = true
+ this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
@@ -978,6 +983,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.client.disconnect()
}
+ async establishLifecycleLease(): Promise {
+ if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
+ return
+ }
+ // Why: an authenticated pair cancels the launch-adoption watchdog and gives
+ // a never-used adapter authority to retire its empty daemon during clean quit.
+ await this.client.ensureConnected()
+ }
+
// Why: for in-process daemon mode, disconnect without flushing history.
// dispose() writes endedAt for all sessions, which would prevent cold
// restore. disconnectOnly() leaves history files in unclean state so
@@ -985,6 +999,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
// We write a final checkpoint before disconnecting so that if the daemon
// later crashes while Orca is closed, checkpoint.json has recovery data.
async disconnectOnly(): Promise {
+ this.respawnAdoptionClosed = true
+ this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
// Why: wait for any in-flight timer pass to finish before starting
// the final checkpoint. Otherwise both passes race on the shared tmp
@@ -1011,11 +1027,31 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.producerResumesOwedOnReconnect.clear()
this.removeEventListener?.()
this.removeEventListener = null
+ if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
+ try {
+ // Why: only the authenticated daemon can atomically prove it is empty;
+ // one shared budget keeps a first connection plus retirement off quit's critical path.
+ const deadlineMs = Date.now() + 250
+ if (!this.client.isConnected()) {
+ await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
+ }
+ await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now()))
+ } catch {
+ // An unreachable daemon falls back to event-driven retirement when its
+ // authenticated sockets close and it can prove itself empty.
+ }
+ }
this.client.disconnect()
}
private async ensureConnected(): Promise {
- await this.client.ensureConnected()
+ try {
+ await this.client.ensureConnected()
+ } finally {
+ // Why: a respawn launcher holds a temporary full pair until this adapter
+ // has attempted its permanent reconnect, preventing both gaps and leaks.
+ this.releasePendingRespawnAdoptionLease()
+ }
// Why sampled before setupEventRouting: routing is (re)installed exactly
// once per connection, so "no listener yet" identifies a fresh connect —
// the only time the daemon-side backgrounded set needs a resync (it is
@@ -1298,7 +1334,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
try {
return await fn()
} catch (err) {
- if (!this.respawnFn || !isDaemonGoneError(err)) {
+ // Why: self-retirement removes the token only after an authenticated
+ // endpoint dropped; an initial missing token may still hide a live daemon.
+ const missingRetiredEndpointToken =
+ isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
+ if (
+ this.respawnAdoptionClosed ||
+ !this.respawnFn ||
+ (!isDaemonGoneError(err) && !missingRetiredEndpointToken)
+ ) {
throw err
}
if (!this.respawnPromise) {
@@ -1307,7 +1351,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
}
await this.respawnPromise
- return await fn()
+ try {
+ return await fn()
+ } finally {
+ // Why: the retried operation may reject before it reaches a connection
+ // attempt (for example, a tombstone racing respawn).
+ this.releasePendingRespawnAdoptionLease()
+ }
}
}
@@ -1371,7 +1421,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
- await this.respawnFn!()
+ const releaseAdoptionLease = await this.respawnFn!()
+ if (this.respawnAdoptionClosed) {
+ // Why: app teardown may win while the launcher is still acquiring its
+ // temporary pair; a late result must not reinstall a lease nobody owns.
+ releaseAdoptionLease?.()
+ throw new Error('Daemon adapter closed during respawn')
+ }
+ this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null
+ }
+
+ private releasePendingRespawnAdoptionLease(): void {
+ const release = this.pendingRespawnAdoptionRelease
+ this.pendingRespawnAdoptionRelease = null
+ release?.()
}
private setupEventRouting(): void {
@@ -1476,5 +1539,18 @@ function isDaemonGoneError(err: unknown): boolean {
return true
}
const msg = err.message
- return msg === 'Connection lost' || msg === 'Not connected' || msg === 'Hello response timed out'
+ return (
+ msg === 'Connection lost' ||
+ msg === 'Not connected' ||
+ msg === 'Hello response timed out' ||
+ msg === 'Daemon temporarily unavailable; reconnect'
+ )
+}
+
+function isMissingTokenFileError(err: unknown): boolean {
+ if (!(err instanceof Error)) {
+ return false
+ }
+ const errno = err as NodeJS.ErrnoException
+ return errno.code === 'ENOENT' && errno.syscall === 'open'
}
diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts
new file mode 100644
index 00000000000..a7184272111
--- /dev/null
+++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts
@@ -0,0 +1,228 @@
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DaemonPtyAdapter } from './daemon-pty-adapter'
+import { DaemonServer } from './daemon-server'
+import { getDaemonSocketPath } from './daemon-spawner'
+import type { SubprocessHandle } from './session'
+
+function fixtureSubprocess(): SubprocessHandle {
+ let onExit: ((code: number) => void) | null = null
+ return {
+ pid: process.pid,
+ getForegroundProcess: () => null,
+ write: () => {},
+ resize: () => {},
+ kill: () => queueMicrotask(() => onExit?.(0)),
+ forceKill: () => queueMicrotask(() => onExit?.(137)),
+ signal: () => {},
+ onData: () => {},
+ onExit: (callback) => {
+ onExit = callback
+ },
+ dispose: () => {}
+ }
+}
+
+async function waitFor(predicate: () => boolean): Promise {
+ const deadline = Date.now() + 2_000
+ while (!predicate()) {
+ if (Date.now() >= deadline) {
+ throw new Error('Timed out waiting for daemon disconnect')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+}
+
+describe('daemon self-retirement respawn', () => {
+ let dir: string
+ let socketPath: string
+ let tokenPath: string
+ let server: DaemonServer | null
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'daemon-retirement-respawn-'))
+ socketPath = getDaemonSocketPath(dir)
+ tokenPath = join(dir, 'daemon.token')
+ server = null
+ })
+
+ afterEach(async () => {
+ await server?.shutdown().catch(() => {})
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ async function startServer(): Promise {
+ const next = new DaemonServer({
+ socketPath,
+ tokenPath,
+ spawnSubprocess: () => fixtureSubprocess()
+ })
+ await next.start()
+ server = next
+ return next
+ }
+
+ it('coalesces respawn after an authenticated endpoint removes its token', async () => {
+ const original = await startServer()
+ const respawn = vi.fn(async () => {
+ await startServer()
+ })
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+ await adapter.listProcesses()
+ const client = (
+ adapter as unknown as {
+ client: { hasObservedAuthenticatedDisconnect(): boolean }
+ }
+ ).client
+
+ await original.shutdown()
+ await waitFor(() => client.hasObservedAuthenticatedDisconnect())
+
+ await Promise.all([
+ adapter.spawn({ sessionId: 'first', cols: 80, rows: 24 }),
+ adapter.spawn({ sessionId: 'second', cols: 80, rows: 24 })
+ ])
+
+ expect(respawn).toHaveBeenCalledTimes(1)
+ adapter.dispose()
+ })
+
+ it('releases the temporary respawn lease before clean retirement', async () => {
+ const original = await startServer()
+ let temporaryAdapter: DaemonPtyAdapter | null = null
+ const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
+ const respawn = vi.fn(async () => {
+ await startServer()
+ temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
+ await temporaryAdapter.establishLifecycleLease()
+ return releaseTemporaryLease
+ })
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+ await adapter.listProcesses()
+ const client = (
+ adapter as unknown as {
+ client: { hasObservedAuthenticatedDisconnect(): boolean }
+ }
+ ).client
+ await original.shutdown()
+ await waitFor(() => client.hasObservedAuthenticatedDisconnect())
+
+ await adapter.spawn({ sessionId: 'respawned', cols: 80, rows: 24 })
+ expect(releaseTemporaryLease).toHaveBeenCalledOnce()
+ await adapter.shutdown('respawned', { immediate: true })
+ await adapter.disconnectOnly()
+
+ await waitFor(() => !existsSync(tokenPath))
+ })
+
+ it('releases a temporary lease when a tombstone wins the respawn race', async () => {
+ const original = await startServer()
+ let temporaryAdapter: DaemonPtyAdapter | null = null
+ const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
+ let adapter!: DaemonPtyAdapter
+ const respawn = vi.fn(async () => {
+ await startServer()
+ temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
+ await temporaryAdapter.establishLifecycleLease()
+ const tombstones = (adapter as unknown as { killedSessionTombstones: Map })
+ .killedSessionTombstones
+ tombstones.set('closed-during-respawn', Date.now())
+ return releaseTemporaryLease
+ })
+ adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+ await adapter.listProcesses()
+ const client = (
+ adapter as unknown as {
+ client: { hasObservedAuthenticatedDisconnect(): boolean }
+ }
+ ).client
+ await original.shutdown()
+ await waitFor(() => client.hasObservedAuthenticatedDisconnect())
+
+ await expect(
+ adapter.spawn({ sessionId: 'closed-during-respawn', cols: 80, rows: 24 })
+ ).rejects.toThrow('was explicitly killed')
+
+ expect(releaseTemporaryLease).toHaveBeenCalledOnce()
+ adapter.dispose()
+ })
+
+ it('releases a lease returned after disposal and does not reconnect', async () => {
+ const original = await startServer()
+ let temporaryAdapter: DaemonPtyAdapter | null = null
+ const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose())
+ let returnRespawnLease!: () => void
+ const respawn = vi.fn(async () => {
+ await startServer()
+ temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath })
+ await temporaryAdapter.establishLifecycleLease()
+ await new Promise((resolve) => {
+ returnRespawnLease = resolve
+ })
+ return releaseTemporaryLease
+ })
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+ await adapter.listProcesses()
+ const client = (
+ adapter as unknown as {
+ client: { hasObservedAuthenticatedDisconnect(): boolean; isConnected(): boolean }
+ }
+ ).client
+ await original.shutdown()
+ await waitFor(() => client.hasObservedAuthenticatedDisconnect())
+
+ const spawn = adapter.spawn({ sessionId: 'disposed', cols: 80, rows: 24 })
+ await waitFor(() => returnRespawnLease !== undefined)
+ adapter.dispose()
+ returnRespawnLease()
+
+ await expect(spawn).rejects.toThrow('closed during respawn')
+ expect(releaseTemporaryLease).toHaveBeenCalledOnce()
+ expect(client.isConnected()).toBe(false)
+ })
+
+ it('does not start a respawn after disposal rejects an in-flight operation', async () => {
+ await startServer()
+ const respawn = vi.fn(async () => {})
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+ await adapter.establishLifecycleLease()
+ let rejectCreate!: (error: Error) => void
+ const client = (
+ adapter as unknown as {
+ client: {
+ request: (method: string, params: unknown) => Promise
+ }
+ }
+ ).client
+ vi.spyOn(client, 'request').mockImplementationOnce(
+ () =>
+ new Promise((_, reject) => {
+ rejectCreate = reject
+ })
+ )
+
+ const spawn = adapter.spawn({ sessionId: 'disposed-in-flight', cols: 80, rows: 24 })
+ await waitFor(() => rejectCreate !== undefined)
+ adapter.dispose()
+ rejectCreate(new Error('Connection lost'))
+
+ await expect(spawn).rejects.toThrow('Connection lost')
+ expect(respawn).not.toHaveBeenCalled()
+ })
+
+ it('does not treat an initial missing token as respawn authority', async () => {
+ const respawn = vi.fn(async () => {})
+ const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
+
+ await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject(
+ {
+ code: 'ENOENT'
+ }
+ )
+
+ expect(respawn).not.toHaveBeenCalled()
+ adapter.dispose()
+ })
+})
diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts
index 40a624103cc..aedf2384e08 100644
--- a/src/main/daemon/daemon-server.test.ts
+++ b/src/main/daemon/daemon-server.test.ts
@@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
import type { SubprocessHandle } from './session'
-import { getDaemonSocketPath } from './daemon-spawner'
+import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
const confirmForegroundProcessMock = vi.fn(async () => 'droid')
@@ -58,6 +58,7 @@ type DaemonServerPrivate = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
+ authenticatedPairEstablished: boolean
}
>
routeRequest(clientId: string, request: DaemonRequest): Promise
@@ -67,6 +68,7 @@ describe('DaemonServer', () => {
let dir: string
let socketPath: string
let tokenPath: string
+ let pidPath: string
let server: DaemonServer
let client: DaemonClient
@@ -75,6 +77,7 @@ describe('DaemonServer', () => {
dir = createTestDir()
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'test.token')
+ pidPath = getDaemonPidPath(dir)
})
afterEach(async () => {
@@ -83,10 +86,11 @@ describe('DaemonServer', () => {
rmSync(dir, { recursive: true, force: true })
})
- async function startServer(): Promise {
+ async function startServer(launchNonce?: string): Promise {
server = new DaemonServer({
socketPath,
tokenPath,
+ ...(launchNonce ? { pidPath, launchNonce } : {}),
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
@@ -290,12 +294,12 @@ describe('DaemonServer', () => {
spawnSubprocess
})
await server.start()
- const daemon = server as unknown as DaemonServerPrivate
+ const c = await connectClient()
- const create = daemon.routeRequest('shutdown-client', {
- id: 'shutdown-create',
- type: 'createOrAttach',
- payload: { sessionId: 'shutdown-pending', cols: 80, rows: 24 }
+ const create = c.request('createOrAttach', {
+ sessionId: 'shutdown-pending',
+ cols: 80,
+ rows: 24
})
const canceledCreate = expect(create).rejects.toThrow(
'Attach canceled for session shutdown-pending'
@@ -535,7 +539,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
- streamSocket
+ streamSocket,
+ authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
@@ -591,7 +596,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
- streamSocket
+ streamSocket,
+ authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
@@ -648,7 +654,8 @@ describe('DaemonServer', () => {
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
- streamSocket
+ streamSocket,
+ authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
id: 'req-1',
@@ -780,6 +787,68 @@ describe('DaemonServer', () => {
})
describe('shutdown', () => {
+ it('waits for the ordinary shutdown reply write before destroying resources', async () => {
+ await startServer()
+ const c = await connectClient()
+ const daemon = server as unknown as DaemonServerPrivate & {
+ host: { dispose: () => Promise }
+ }
+ const controlSocket = [...daemon.clients.values()][0].controlSocket
+ const originalWrite = controlSocket.write.bind(controlSocket)
+ let replyFlushed: (() => void) | undefined
+ vi.spyOn(controlSocket, 'write').mockImplementation(((
+ chunk: string | Uint8Array,
+ ...args: unknown[]
+ ) => {
+ replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined
+ return originalWrite(chunk)
+ }) as unknown as Socket['write'])
+ const dispose = vi.spyOn(daemon.host, 'dispose')
+
+ await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({})
+ expect(dispose).not.toHaveBeenCalled()
+ expect(existsSync(tokenPath)).toBe(true)
+
+ replyFlushed?.()
+ await waitFor(() => !existsSync(tokenPath))
+ expect(dispose).toHaveBeenCalledOnce()
+ })
+
+ it('removes only its owned token and PID record', async () => {
+ const launchNonce = 'ordinary-shutdown'
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce })
+ )
+ await startServer(launchNonce)
+
+ await server.shutdown()
+
+ expect(existsSync(tokenPath)).toBe(false)
+ expect(existsSync(pidPath)).toBe(false)
+ })
+
+ it('preserves token and PID artifacts replaced before ordinary cleanup', async () => {
+ await startServer('mine')
+ writeFileSync(tokenPath, 'replacement-token')
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({
+ pid: process.pid,
+ startedAtMs: null,
+ launchNonce: 'replacement'
+ })
+ )
+
+ await server.shutdown()
+
+ expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token')
+ expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({
+ pid: process.pid,
+ launchNonce: 'replacement'
+ })
+ })
+
it('stops accepting connections after shutdown', async () => {
await startServer()
await server.shutdown()
@@ -800,9 +869,7 @@ describe('DaemonServer', () => {
)
const c = await connectClient()
- // The daemon may self-terminate before the reply flushes; callers treat
- // that as success, so only the observable teardown below is asserted.
- await c.request('shutdown', { killSessions: true }).catch(() => {})
+ await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
await waitFor(() => daemon.server === null)
await waitFor(() => !existsSync(socketPath))
diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts
index c5d6c04dfee..15ec9fecd21 100644
--- a/src/main/daemon/daemon-server.ts
+++ b/src/main/daemon/daemon-server.ts
@@ -4,7 +4,7 @@
import { createServer, type Server, type Socket } from 'node:net'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
-import { writeFileSync, chmodSync, unlinkSync } from 'node:fs'
+import { writeFileSync, chmodSync } from 'node:fs'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import { TerminalHost } from './terminal-host'
@@ -23,7 +23,9 @@ import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
import { isTuiAgent } from '../../shared/tui-agent-config'
+import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
import {
+ CLEAN_DISCONNECT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
NOTIFY_PREFIX,
SessionNotFoundError,
@@ -35,6 +37,21 @@ import {
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
+ pidPath?: string
+ launchNonce?: string
+ startedAtMs?: number
+ /** Direct-construction seam for protocol fixture tests; production never overrides it. */
+ protocolVersion?: number
+ onIdleShutdown?: () => void
+ /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */
+ initialAdoptionTestConfig?: {
+ timeoutMs: number
+ clock: {
+ setTimeout(callback: () => void, delayMs: number): unknown
+ clearTimeout(handle: unknown): void
+ now(): number
+ }
+ }
ptySpawnHealthCheck?: () => Promise
preparePtySpawn?: () => Promise
log?: DaemonFileLog
@@ -53,21 +70,46 @@ type ConnectedClient = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
+ authenticatedPairEstablished: boolean
}
type PendingPtySpawnPreparation = {
canceled: boolean
}
+type PendingShutdownReply = {
+ start: () => void
+}
+
export class DaemonServer {
+ // Why: a new daemon must survive long enough for its first client pair, but
+ // a parent crash between launch and adoption must not orphan it forever.
+ private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
+ private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
private server: Server | null = null
private token: string
private host: TerminalHost
private socketPath: string
private tokenPath: string
+ private pidPath: string | null
+ private launchNonce: string | null
+ private startedAtMs: number | null
+ private protocolVersion: number
+ private onIdleShutdown: () => void
private ptySpawnHealthCheck: () => Promise
private preparePtySpawn: () => Promise
private log: DaemonFileLog
+ private transportSockets = new Set()
+ private createOrAttachInFlight = 0
+ private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running'
+ private initialAdoptionTimer: unknown | null = null
+ private initialAdoptionDeadlineMs: number | null = null
+ private retirementRequested = false
+ private shutdownPromise: Promise | null = null
+ private ordinaryShutdownServerClose: Promise | null = null
+ private pendingShutdownReplies = new Map()
+ private initialAdoptionTimeoutMs: number
+ private lifecycleClock: NonNullable['clock']
private clients = new Map()
private streamDataBatcher = new DaemonStreamDataBatcher(
@@ -116,6 +158,28 @@ export class DaemonServer {
constructor(opts: DaemonServerOptions) {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
+ this.pidPath = opts.pidPath ?? null
+ this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
+ this.launchNonce =
+ opts.launchNonce ??
+ (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null)
+ this.startedAtMs =
+ opts.startedAtMs ??
+ (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
+ ? Date.now() - process.uptime() * 1000
+ : null)
+ this.onIdleShutdown = opts.onIdleShutdown ?? (() => {})
+ this.initialAdoptionTimeoutMs =
+ opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS
+ this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? {
+ setTimeout: (callback, delayMs) => {
+ const timer = setTimeout(callback, delayMs)
+ timer.unref()
+ return timer
+ },
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType),
+ now: () => Date.now()
+ }
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
@@ -150,12 +214,48 @@ export class DaemonServer {
} catch {
// Best-effort on platforms that support it
}
+ if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
+ // Why: a parent crash before the first full client pair must not leave
+ // a freshly published, empty daemon alive forever.
+ this.armInitialAdoptionTimeout()
+ }
resolve()
})
})
}
async shutdown(): Promise {
+ if (this.shutdownPromise) {
+ return this.shutdownPromise
+ }
+ const serverClose = this.beginOrdinaryShutdownFence()
+ this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
+ return this.shutdownPromise
+ }
+
+ private beginOrdinaryShutdownFence(): Promise {
+ this.idleShutdownState = 'shutting-down'
+ this.cancelInitialAdoptionTimer()
+ this.ordinaryShutdownServerClose ??= this.beginServerClose()
+ return this.ordinaryShutdownServerClose
+ }
+
+ private async finishOrdinaryShutdown(serverClose: Promise): Promise {
+ this.unlinkOwnedEndpointArtifacts()
+ await this.disposeDaemonResources()
+ await serverClose
+ }
+
+ private unlinkOwnedEndpointArtifacts(): void {
+ // Why: close has already fenced this endpoint, but ownership checks still
+ // prevent a late replacement's canonical token or PID record from removal.
+ unlinkOwnedDaemonTokenFile(this.tokenPath, this.token)
+ if (this.pidPath && this.launchNonce) {
+ unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
+ }
+ }
+
+ private async disposeDaemonResources(): Promise {
this.stopStreamBacklogProbe()
this.transientFactRelay.dispose()
this.cancelAllPendingPtySpawnPreparations()
@@ -169,29 +269,142 @@ export class DaemonServer {
})
}
this.streamDataBatcher.clear()
+ this.pendingShutdownReplies.clear()
for (const [, client] of this.clients) {
client.controlSocket.destroy()
client.streamSocket?.destroy()
}
this.clients.clear()
+ for (const socket of this.transportSockets) {
+ socket.destroy()
+ }
+ this.transportSockets.clear()
+ }
+ private beginServerClose(): Promise {
+ const server = this.server
+ this.server = null
+ if (!server) {
+ return Promise.resolve()
+ }
return new Promise((resolve) => {
- if (this.server) {
- this.server.close(() => {
- try {
- unlinkSync(this.socketPath)
- } catch {}
- resolve()
- })
- this.server = null
- } else {
+ // Why: call close synchronously before any awaited cleanup so no new
+ // transport can enter after the idle fence is proven empty.
+ server.close(() => {
+ // Node owns unlinking its Unix listener. An extra check-then-unlink here could
+ // delete a replacement endpoint installed concurrently after close.
resolve()
- }
+ })
})
}
+ private isIdle(): boolean {
+ return (
+ this.transportSockets.size === 0 &&
+ this.clients.size === 0 &&
+ this.createOrAttachInFlight === 0 &&
+ this.host.listSessions().length === 0
+ )
+ }
+
+ private reevaluateIdleShutdown(): void {
+ if (this.idleShutdownState !== 'running') {
+ return
+ }
+ if (this.retirementRequested) {
+ this.cancelInitialAdoptionTimer()
+ if (this.isIdle()) {
+ this.beginIdleShutdown()
+ }
+ return
+ }
+ if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) {
+ this.cancelInitialAdoptionTimer()
+ return
+ }
+ if (this.initialAdoptionTimer !== null) {
+ return
+ }
+ const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now())
+ if (remainingMs === 0) {
+ this.initialAdoptionDeadlineMs = null
+ this.retirementRequested = true
+ this.beginIdleShutdown()
+ return
+ }
+ this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => {
+ this.initialAdoptionTimer = null
+ this.initialAdoptionDeadlineMs = null
+ this.retirementRequested = true
+ this.beginIdleShutdown()
+ }, remainingMs)
+ }
+
+ private armInitialAdoptionTimeout(): void {
+ this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs
+ this.reevaluateIdleShutdown()
+ }
+
+ private cancelInitialAdoptionTimer(): void {
+ if (this.initialAdoptionTimer === null) {
+ return
+ }
+ this.lifecycleClock.clearTimeout(this.initialAdoptionTimer)
+ this.initialAdoptionTimer = null
+ }
+
+ private beginIdleShutdown(): void {
+ this.initialAdoptionTimer = null
+ if (this.idleShutdownState !== 'running') {
+ return
+ }
+ this.idleShutdownState = 'idle-shutdown-pending'
+ if (!this.isIdle()) {
+ // Why: work admitted before the fence wins. Clearing the pending state
+ // keeps that already-started client/session fully usable.
+ this.idleShutdownState = 'running'
+ this.reevaluateIdleShutdown()
+ return
+ }
+
+ this.idleShutdownState = 'shutting-down'
+ // beginServerClose() runs synchronously up to server.close(), before host
+ // disposal or file cleanup can yield to a racing connection.
+ const serverClose = this.beginServerClose()
+ this.shutdownPromise = this.finishIdleShutdown(serverClose)
+ }
+
+ private async finishIdleShutdown(serverClose: Promise): Promise {
+ this.unlinkOwnedEndpointArtifacts()
+ await this.disposeDaemonResources()
+ await serverClose
+ this.onIdleShutdown()
+ }
+
private handleConnection(socket: Socket): void {
+ this.cancelInitialAdoptionTimer()
+ this.transportSockets.add(socket)
+ const removeTransport = (): void => {
+ this.transportSockets.delete(socket)
+ this.reevaluateIdleShutdown()
+ }
+ socket.once('close', removeTransport)
+ socket.on('error', () => socket.destroy())
+
+ if (this.idleShutdownState !== 'running') {
+ // Why: an accepted connection queued just before server.close() must get
+ // an explicit retry signal instead of appearing authenticated then dying.
+ socket.end(
+ encodeNdjson({
+ type: 'hello',
+ ok: false,
+ error: 'Daemon temporarily unavailable; reconnect',
+ retryable: true
+ })
+ )
+ return
+ }
// Why: clients can send multibyte prompt/input text split across socket
// chunks; keep UTF-8 sequences intact before NDJSON parsing.
const decoder = new StringDecoder('utf8')
@@ -203,7 +416,6 @@ export class DaemonServer {
)
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
- socket.on('error', () => socket.destroy())
}
private handleFirstMessage(
@@ -219,7 +431,7 @@ export class DaemonServer {
return
}
- if (hello.version !== PROTOCOL_VERSION) {
+ if (hello.version !== this.protocolVersion) {
this.log.log('client-hello-rejected', {
reason: 'protocol-mismatch',
clientVersion: hello.version
@@ -237,18 +449,34 @@ export class DaemonServer {
}
this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
- socket.write(encodeNdjson({ type: 'hello', ok: true }))
+ socket.write(
+ encodeNdjson({
+ type: 'hello',
+ ok: true,
+ ...(this.launchNonce && this.startedAtMs
+ ? {
+ daemonIdentity: {
+ pid: process.pid,
+ startedAtMs: this.startedAtMs,
+ launchNonce: this.launchNonce
+ }
+ }
+ : {})
+ })
+ )
if (hello.role === 'control') {
const previous = this.clients.get(hello.clientId)
const client: ConnectedClient = {
clientId: hello.clientId,
controlSocket: socket,
- streamSocket: null
+ streamSocket: null,
+ authenticatedPairEstablished: false
}
this.clients.set(hello.clientId, client)
this.setupControlSocket(socket, hello.clientId)
if (previous) {
+ this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
// Why: a reconnect can reuse a clientId before the old sockets notice
// their close. Tear them down after installing the new owner so stale
// close events cannot delete the replacement client entry.
@@ -264,6 +492,12 @@ export class DaemonServer {
return
}
this.setupStreamSocket(socket, client)
+ client.authenticatedPairEstablished = true
+ // A complete app connection, unlike a health or raw socket probe, owns
+ // the endpoint again and cancels pending event-driven retirement.
+ this.initialAdoptionDeadlineMs = null
+ this.retirementRequested = false
+ this.cancelInitialAdoptionTimer()
}
}
@@ -285,12 +519,28 @@ export class DaemonServer {
if (client?.controlSocket !== socket) {
return
}
+ const wasFullyAuthenticated = client.authenticatedPairEstablished
this.streamDataBatcher.clear(clientId)
client.streamSocket?.destroy()
this.clients.delete(clientId)
+ this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
+ this.reevaluateIdleShutdown()
})
}
+ private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
+ if (
+ !wasFullyAuthenticated ||
+ [...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) ||
+ this.idleShutdownState !== 'running'
+ ) {
+ return
+ }
+ // Why: once the last full client is gone, exact daemon-side emptiness is
+ // sufficient; incomplete transports may block but never erase this request.
+ this.retirementRequested = true
+ }
+
private setupStreamSocket(socket: Socket, client: ConnectedClient): void {
const previous = client.streamSocket
socket.removeAllListeners('data')
@@ -330,7 +580,12 @@ export class DaemonServer {
try {
const result = await this.routeRequest(clientId, request)
if (!isNotify) {
- socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }))
+ const pendingShutdown = this.pendingShutdownReplies.get(
+ this.shutdownReplyKey(clientId, request.id)
+ )
+ socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => {
+ pendingShutdown?.start()
+ })
}
} catch (err) {
if (!isNotify) {
@@ -345,6 +600,41 @@ export class DaemonServer {
}
}
+ private shutdownReplyKey(clientId: string, requestId: string): string {
+ return `${clientId}\u0000${requestId}`
+ }
+
+ private deferShutdownUntilReply(
+ clientId: string,
+ requestId: string,
+ socket: Socket,
+ finish: () => Promise
+ ): void {
+ const key = this.shutdownReplyKey(clientId, requestId)
+ let started = false
+ let timer: ReturnType
+ const start = (): void => {
+ if (started) {
+ return
+ }
+ started = true
+ clearTimeout(timer)
+ socket.off('close', start)
+ socket.off('error', start)
+ this.pendingShutdownReplies.delete(key)
+ if (!this.shutdownPromise) {
+ this.shutdownPromise = finish()
+ }
+ }
+ // Why: a non-reading authenticated peer must not pin a fenced daemon by
+ // holding its acknowledgement behind permanent socket backpressure.
+ timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
+ timer.unref()
+ socket.once('close', start)
+ socket.once('error', start)
+ this.pendingShutdownReplies.set(key, { start })
+ }
+
private async preparePtySpawnUnlessCanceled(sessionId: string): Promise {
const preparation: PendingPtySpawnPreparation = { canceled: false }
const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set()
@@ -387,64 +677,80 @@ export class DaemonServer {
switch (request.type) {
case 'createOrAttach': {
+ if (this.idleShutdownState !== 'running') {
+ throw new Error('Daemon temporarily unavailable; reconnect')
+ }
+ if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
+ // Why: a control-only replacement cannot own terminal admission or
+ // erase the prior full client's monotonic retirement request.
+ throw new Error('Daemon client connection is incomplete; reconnect')
+ }
+ this.createOrAttachInFlight++
const p = request.payload
- await this.preparePtySpawnUnlessCanceled(p.sessionId)
- const result = await this.host.createOrAttach({
- sessionId: p.sessionId,
- cols: p.cols,
- rows: p.rows,
- cwd: p.cwd,
- env: p.env,
- envToDelete: p.envToDelete,
- command: p.command,
- startupCommandDelivery: p.startupCommandDelivery,
- // Why: daemon RPC payloads are untrusted JSON. Persist only the
- // allowlisted enum used for byte routing, never arbitrary identity.
- ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
- shellOverride: p.shellOverride,
- terminalWindowsWslDistro: p.terminalWindowsWslDistro,
- terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
- shellReadySupported: p.shellReadySupported,
- historySeed: p.historySeed,
- ...(p.shellReadyTimeoutMs !== undefined
- ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
- : {}),
- streamClient: {
- onData: (data) => {
- // Scan BEFORE enqueue: the batcher may keep-tail drop this
- // chunk, but its facts must be captured regardless.
- this.transientFactRelay.onSessionData(p.sessionId, data)
- const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
- const isInteractiveOutput =
- data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
- lastInputAt !== undefined &&
- performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
- this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
- flushImmediately: isInteractiveOutput,
- flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS
- })
- },
- onExit: (code) => {
- // Why: exit tears down renderer handlers, so it must ride the
- // ordered queue behind final output even when the shallow socket
- // gate holds that output for a later drain pass.
- this.log.log('session-exited', { sessionId: p.sessionId, code })
- this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
- type: 'event',
- event: 'exit',
- sessionId: p.sessionId,
- payload: { code }
- })
- this.streamDataBatcher.flush(clientId)
- recordDaemonStreamBacklogEvent('sessionExit', {
- sessionIdSuffix: p.sessionId.slice(-10)
- })
- this.transientFactRelay.onSessionExit(p.sessionId)
- this.streamClientIdBySessionId.delete(p.sessionId)
- this.lastInputAtBySessionId.delete(p.sessionId)
+ let result: Awaited>
+ try {
+ await this.preparePtySpawnUnlessCanceled(p.sessionId)
+ result = await this.host.createOrAttach({
+ sessionId: p.sessionId,
+ cols: p.cols,
+ rows: p.rows,
+ cwd: p.cwd,
+ env: p.env,
+ envToDelete: p.envToDelete,
+ command: p.command,
+ startupCommandDelivery: p.startupCommandDelivery,
+ // Why: daemon RPC payloads are untrusted JSON. Persist only the
+ // allowlisted enum used for byte routing, never arbitrary identity.
+ ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
+ shellOverride: p.shellOverride,
+ terminalWindowsWslDistro: p.terminalWindowsWslDistro,
+ terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
+ shellReadySupported: p.shellReadySupported,
+ historySeed: p.historySeed,
+ ...(p.shellReadyTimeoutMs !== undefined
+ ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
+ : {}),
+ streamClient: {
+ onData: (data) => {
+ // Scan BEFORE enqueue: the batcher may keep-tail drop this
+ // chunk, but its facts must be captured regardless.
+ this.transientFactRelay.onSessionData(p.sessionId, data)
+ const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
+ const isInteractiveOutput =
+ data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
+ lastInputAt !== undefined &&
+ performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
+ this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
+ flushImmediately: isInteractiveOutput,
+ flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS
+ })
+ },
+ onExit: (code) => {
+ // Why: exit tears down renderer handlers, so it must ride the
+ // ordered queue behind final output even when the shallow socket
+ // gate holds that output for a later drain pass.
+ this.log.log('session-exited', { sessionId: p.sessionId, code })
+ this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
+ type: 'event',
+ event: 'exit',
+ sessionId: p.sessionId,
+ payload: { code }
+ })
+ this.streamDataBatcher.flush(clientId)
+ recordDaemonStreamBacklogEvent('sessionExit', {
+ sessionIdSuffix: p.sessionId.slice(-10)
+ })
+ this.transientFactRelay.onSessionExit(p.sessionId)
+ this.streamClientIdBySessionId.delete(p.sessionId)
+ this.lastInputAtBySessionId.delete(p.sessionId)
+ this.reevaluateIdleShutdown()
+ }
}
- }
- })
+ })
+ } finally {
+ this.createOrAttachInFlight--
+ this.reevaluateIdleShutdown()
+ }
this.streamClientIdBySessionId.set(p.sessionId, clientId)
// Why an attach-time marker: the adapter resyncs the background set on
// a fresh connection, which can precede this attach — main's scan
@@ -599,6 +905,35 @@ export class DaemonServer {
case 'listSessions':
return { sessions: this.host.listSessions() }
+ case 'shutdownIfIdle': {
+ const authenticatedClient = this.clients.get(clientId)
+ const retiring =
+ authenticatedClient !== undefined &&
+ authenticatedClient.streamSocket !== null &&
+ this.clients.size === 1 &&
+ this.createOrAttachInFlight === 0 &&
+ this.host.listSessions().length === 0 &&
+ [...this.transportSockets].every(
+ (transport) =>
+ transport === authenticatedClient.controlSocket ||
+ transport === authenticatedClient.streamSocket
+ )
+ if (!retiring) {
+ return { retiring: false }
+ }
+ this.idleShutdownState = 'shutting-down'
+ this.initialAdoptionDeadlineMs = null
+ this.retirementRequested = false
+ this.cancelInitialAdoptionTimer()
+ // Why: close before acknowledging retirement so no new terminal can
+ // race between the empty proof and daemon disposal.
+ const serverClose = this.beginServerClose()
+ this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () =>
+ this.finishIdleShutdown(serverClose)
+ )
+ return { retiring: true }
+ }
+
case 'getSnapshot': {
const snapshotStart = performance.now()
const requestedScrollbackRows = request.payload.scrollbackRows
@@ -644,11 +979,12 @@ export class DaemonServer {
await this.ptySpawnHealthCheck()
return { healthy: true }
- case 'shutdown':
+ case 'shutdown': {
this.log.log('shutdown', {
reason: 'rpc',
killSessions: request.payload.killSessions === true
})
+ const serverClose = this.beginOrdinaryShutdownFence()
if (request.payload.killSessions) {
try {
await this.host.dispose()
@@ -661,8 +997,16 @@ export class DaemonServer {
})
}
}
- process.nextTick(() => this.shutdown())
+ const controlSocket = this.clients.get(clientId)?.controlSocket
+ if (controlSocket) {
+ this.deferShutdownUntilReply(clientId, request.id, controlSocket, () =>
+ this.finishOrdinaryShutdown(serverClose)
+ )
+ } else if (!this.shutdownPromise) {
+ this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
+ }
return {}
+ }
}
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts
index f0367319ed5..613b9e6a149 100644
--- a/src/main/daemon/daemon-spawner.test.ts
+++ b/src/main/daemon/daemon-spawner.test.ts
@@ -1,12 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
DaemonSpawner,
getDaemonPidPath,
getDaemonSocketPath,
- getDaemonTokenPath
+ getDaemonTokenPath,
+ restoreClaimedDaemonArtifact
} from './daemon-spawner'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
@@ -70,6 +71,20 @@ describe('DaemonSpawner', () => {
}
describe('ensureRunning', () => {
+ it('passes the scoped PID path and a fresh launch nonce to the launcher', async () => {
+ const launcher = vi.fn(async () => ({ shutdown: vi.fn(async () => {}) }))
+ spawner = new DaemonSpawner({ runtimeDir: dir, launcher })
+
+ await spawner.ensureRunning()
+
+ expect(launcher).toHaveBeenCalledWith(
+ getDaemonSocketPath(dir),
+ getDaemonTokenPath(dir),
+ getDaemonPidPath(dir),
+ expect.stringMatching(/^[0-9a-f-]{36}$/)
+ )
+ })
+
it('uses protocol-scoped socket and token paths', () => {
const socketPath = getDaemonSocketPath(dir)
const tokenPath = getDaemonTokenPath(dir)
@@ -172,3 +187,51 @@ describe('DaemonSpawner', () => {
})
})
})
+
+describe('restoreClaimedDaemonArtifact', () => {
+ it('retains the unique claim when restoration fails without a replacement', () => {
+ expect(
+ restoreClaimedDaemonArtifact('/claimed', '/canonical', {
+ copyExclusive: () => {
+ throw new Error('injected ENOSPC')
+ },
+ canonicalExists: () => false
+ })
+ ).toBe(false)
+ })
+
+ it('retains the unique claim when a failed copy leaves a partial canonical file', () => {
+ const restoreDir = createTestDir()
+ const canonicalPath = join(restoreDir, 'partial-canonical')
+ try {
+ expect(
+ restoreClaimedDaemonArtifact('/claimed', canonicalPath, {
+ copyExclusive: () => {
+ writeFileSync(canonicalPath, 'partial')
+ throw Object.assign(new Error('injected ENOSPC'), { code: 'ENOSPC' })
+ },
+ canonicalExists: () => true
+ })
+ ).toBe(false)
+ } finally {
+ rmSync(restoreDir, { recursive: true, force: true })
+ }
+ })
+
+ it('allows claim cleanup after successful restore or a confirmed replacement', () => {
+ expect(
+ restoreClaimedDaemonArtifact('/claimed', '/canonical', {
+ copyExclusive: () => {},
+ canonicalExists: () => false
+ })
+ ).toBe(true)
+ expect(
+ restoreClaimedDaemonArtifact('/claimed', '/canonical', {
+ copyExclusive: () => {
+ throw Object.assign(new Error('injected EEXIST'), { code: 'EEXIST' })
+ },
+ canonicalExists: () => true
+ })
+ ).toBe(true)
+ })
+})
diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts
index ef54ed9a25d..ba0ea239c67 100644
--- a/src/main/daemon/daemon-spawner.ts
+++ b/src/main/daemon/daemon-spawner.ts
@@ -1,4 +1,5 @@
-import { createHash } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
+import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from './types'
@@ -12,14 +13,21 @@ export type DaemonPidFile = {
startedAtMs: number | null
entryPath?: string
appVersion?: string
+ launchNonce?: string
}
export type DaemonProcessHandle = {
mode?: 'degraded-new-pty-fallback'
+ releaseAdoptionLease?(): void
shutdown(): Promise
}
-export type DaemonLauncher = (socketPath: string, tokenPath: string) => Promise
+export type DaemonLauncher = (
+ socketPath: string,
+ tokenPath: string,
+ pidPath?: string,
+ launchNonce?: string
+) => Promise
export type DaemonSpawnerOptions = {
runtimeDir: string
@@ -32,12 +40,14 @@ export class DaemonSpawner {
private handle: DaemonProcessHandle | null = null
private socketPath: string
private tokenPath: string
+ private pidPath: string
constructor(opts: DaemonSpawnerOptions) {
this.runtimeDir = opts.runtimeDir
this.launcher = opts.launcher
this.socketPath = getDaemonSocketPath(this.runtimeDir)
this.tokenPath = getDaemonTokenPath(this.runtimeDir)
+ this.pidPath = getDaemonPidPath(this.runtimeDir)
}
async ensureRunning(): Promise {
@@ -45,7 +55,9 @@ export class DaemonSpawner {
return { socketPath: this.socketPath, tokenPath: this.tokenPath }
}
- this.handle = await this.launcher(this.socketPath, this.tokenPath)
+ // Why: a detached daemon may clean up after its parent exits. A unique
+ // launch identity keeps it from deleting a replacement daemon's PID file.
+ this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID())
return { socketPath: this.socketPath, tokenPath: this.tokenPath }
}
@@ -96,3 +108,83 @@ export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_
export function serializeDaemonPidFile(pidFile: DaemonPidFile): string {
return JSON.stringify(pidFile)
}
+
+export function unlinkOwnedDaemonPidFile(
+ pidPath: string,
+ expectedPid: number,
+ expectedLaunchNonce: string
+): boolean {
+ return claimAndUnlinkOwnedFile(pidPath, (content) => {
+ try {
+ const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown }
+ return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce
+ } catch {
+ return false
+ }
+ })
+}
+
+export function unlinkOwnedDaemonTokenFile(tokenPath: string, expectedToken: string): boolean {
+ return claimAndUnlinkOwnedFile(tokenPath, (content) => content.trim() === expectedToken)
+}
+
+function claimAndUnlinkOwnedFile(
+ filePath: string,
+ ownsContent: (content: string) => boolean
+): boolean {
+ const claimedPath = `${filePath}.cleanup-${process.pid}-${randomUUID()}`
+ try {
+ // Why: rename claims one exact directory entry before inspection, so a replacement
+ // installed afterward stays at the canonical path and cannot be unlinked by us.
+ renameSync(filePath, claimedPath)
+ } catch {
+ return false
+ }
+ try {
+ if (ownsContent(readFileSync(claimedPath, 'utf8'))) {
+ unlinkSync(claimedPath)
+ return true
+ }
+ } catch {
+ // Restore below when the claimed file cannot be validated as ours.
+ }
+
+ const restoredOrReplaced = restoreClaimedDaemonArtifact(claimedPath, filePath)
+ if (restoredOrReplaced) {
+ try {
+ unlinkSync(claimedPath)
+ } catch {
+ // A uniquely named unowned claim is safer to leave than overwriting a replacement.
+ }
+ }
+ return false
+}
+
+export function restoreClaimedDaemonArtifact(
+ claimedPath: string,
+ canonicalPath: string,
+ operations: {
+ copyExclusive?: (source: string, target: string) => void
+ canonicalExists?: (path: string) => boolean
+ } = {}
+): boolean {
+ const copyExclusive =
+ operations.copyExclusive ??
+ ((source: string, target: string) => copyFileSync(source, target, constants.COPYFILE_EXCL))
+ const canonicalExists = operations.canonicalExists ?? existsSync
+ try {
+ // Why: exclusive restore never overwrites a newer canonical replacement.
+ copyExclusive(claimedPath, canonicalPath)
+ return true
+ } catch (error) {
+ // Why: copy failures can leave a partial canonical file. Only EEXIST proves
+ // another owner had already installed a replacement before our copy.
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ error.code === 'EEXIST' &&
+ canonicalExists(canonicalPath)
+ )
+ }
+}
diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts
index 59b5c998f74..12edb3b8a7c 100644
--- a/src/main/daemon/production-launcher.test.ts
+++ b/src/main/daemon/production-launcher.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createProductionLauncher } from './production-launcher'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
@@ -63,6 +63,20 @@ describe('createProductionLauncher', () => {
expect(typeof launcher).toBe('function')
})
+ it('rejects either ownership argument without its pair before forking', async () => {
+ const launcher = createProductionLauncher({
+ getDaemonEntryPath: () => '/fake/path.js'
+ })
+
+ await expect(
+ launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid'))
+ ).rejects.toThrow('provided together')
+ await expect(
+ launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a')
+ ).rejects.toThrow('provided together')
+ expect(forkMock).not.toHaveBeenCalled()
+ })
+
it('can be used with DaemonSpawner (in-process fallback)', async () => {
// Use in-process launcher for testing (same as DaemonSpawner tests)
const launcher = async (socketPath: string, tokenPath: string) => {
@@ -115,8 +129,9 @@ describe('createProductionLauncher', () => {
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
})
- const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
- handlers.message[0]?.({ type: 'ready' })
+ const pidPath = join(dir, 'daemon.pid')
+ const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a')
+ handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
const handle = await launch
expect(handle.shutdown).toEqual(expect.any(Function))
@@ -125,9 +140,73 @@ describe('createProductionLauncher', () => {
expect(handlers.exit).toHaveLength(0)
expect(child.disconnect).toHaveBeenCalled()
expect(child.unref).toHaveBeenCalled()
+ expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({
+ pid: 12345,
+ startedAtMs: 123_456,
+ entryPath: join(dir, 'daemon-entry.js'),
+ launchNonce: 'launch-a'
+ })
+ expect(forkMock).toHaveBeenCalledWith(
+ join(dir, 'daemon-entry.js'),
+ expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']),
+ expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] })
+ )
})
- it('removes shutdown exit listener when force-kill timeout settles first', async () => {
+ it('resolves shutdown only after observing child exit', async () => {
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null as number | null,
+ signalCode: null as NodeJS.Signals | null,
+ on: vi.fn((event: string, callback: (arg?: unknown) => void) => {
+ handlers[event]?.push(callback)
+ return child
+ }),
+ once: vi.fn((event: string, callback: (arg?: unknown) => void) => {
+ handlers[event]?.push(callback)
+ return child
+ }),
+ off: vi.fn((event: string, callback: (arg?: unknown) => void) => {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
+ return child
+ }),
+ kill: vi.fn((signal: NodeJS.Signals) => {
+ if (signal === 'SIGTERM') {
+ queueMicrotask(() => {
+ child.exitCode = 0
+ for (const callback of handlers.exit.slice()) {
+ callback(0)
+ }
+ })
+ }
+ return true
+ }),
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const launcher = createProductionLauncher({
+ getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
+ })
+ const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
+ handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
+ const handle = await launch
+
+ await expect(handle.shutdown()).resolves.toBeUndefined()
+
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM')
+ expect(child.exitCode).toBe(0)
+ })
+
+ it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => {
vi.useFakeTimers()
try {
const handlers: Record void)[]> = {
@@ -138,6 +217,9 @@ describe('createProductionLauncher', () => {
const child = {
pid: 12345,
killed: false,
+ connected: false,
+ exitCode: null,
+ signalCode: null,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
@@ -150,8 +232,10 @@ describe('createProductionLauncher', () => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
- kill: vi.fn(),
- disconnect: vi.fn(),
+ kill: vi.fn(() => true),
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
@@ -161,22 +245,135 @@ describe('createProductionLauncher', () => {
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
- handlers.message[0]?.({ type: 'ready' })
+ handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
const handle = await launch
- const shutdown = handle.shutdown()
+ const shutdown = expect(handle.shutdown()).rejects.toThrow(
+ 'Daemon did not exit after SIGKILL'
+ )
expect(handlers.exit).toHaveLength(1)
- await vi.advanceTimersByTimeAsync(5000)
+ await vi.advanceTimersByTimeAsync(6000)
await shutdown
expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
expect(handlers.exit).toHaveLength(0)
+ expect(child.unref).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
+
+ it('preserves readiness and signaling failures while releasing startup IPC', async () => {
+ vi.useFakeTimers()
+ try {
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' })
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null,
+ signalCode: null,
+ on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event]?.push(cb)
+ return child
+ }),
+ once: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event]?.push(cb)
+ return child
+ }),
+ off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
+ return child
+ }),
+ kill: vi.fn(() => {
+ throw signalError
+ }),
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+
+ const launcher = createProductionLauncher({
+ getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
+ })
+ const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
+ handlers.message[0]?.({ type: 'ready' })
+
+ const error = await launch.catch((caught: unknown) => caught)
+ expect(error).toBeInstanceOf(AggregateError)
+ expect((error as AggregateError).errors).toEqual([
+ expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }),
+ signalError
+ ])
+ expect(child.disconnect).toHaveBeenCalledOnce()
+ expect(child.unref).toHaveBeenCalledOnce()
+ expect(handlers.message).toHaveLength(0)
+ expect(handlers.error).toHaveLength(0)
+ expect(handlers.exit).toHaveLength(0)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('preserves PID publication and cleanup failures', async () => {
+ const handlers: Record void)[]> = {
+ message: [],
+ error: [],
+ exit: []
+ }
+ const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' })
+ const child = {
+ pid: 12345,
+ connected: true,
+ exitCode: null,
+ signalCode: null,
+ on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event]?.push(cb)
+ return child
+ }),
+ once: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event]?.push(cb)
+ return child
+ }),
+ off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
+ handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
+ return child
+ }),
+ kill: vi.fn(() => {
+ throw signalError
+ }),
+ disconnect: vi.fn(() => {
+ child.connected = false
+ }),
+ unref: vi.fn()
+ }
+ forkMock.mockReturnValueOnce(child)
+ const pidPath = join(dir, 'occupied.pid')
+ writeFileSync(pidPath, 'occupied')
+ const launcher = createProductionLauncher({
+ getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
+ })
+
+ const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')
+ handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
+
+ const error = await launch.catch((caught: unknown) => caught)
+ expect(error).toBeInstanceOf(AggregateError)
+ expect((error as AggregateError).errors).toEqual([
+ expect.objectContaining({ code: 'EEXIST' }),
+ signalError
+ ])
+ expect(child.disconnect).toHaveBeenCalledOnce()
+ expect(child.unref).toHaveBeenCalledOnce()
+ })
})
function socketPathFor(dir: string): string {
diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts
index 3fb5d7e792c..b9fe704a41d 100644
--- a/src/main/daemon/production-launcher.ts
+++ b/src/main/daemon/production-launcher.ts
@@ -1,5 +1,10 @@
import { fork, type ChildProcess } from 'node:child_process'
-import type { DaemonLauncher, DaemonProcessHandle } from './daemon-spawner'
+import { writeFileSync } from 'node:fs'
+import {
+ serializeDaemonPidFile,
+ type DaemonLauncher,
+ type DaemonProcessHandle
+} from './daemon-spawner'
const READY_TIMEOUT_MS = 10_000
@@ -8,17 +13,63 @@ export type ProductionLauncherOptions = {
}
export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher {
- return async (socketPath: string, tokenPath: string): Promise => {
+ return async (
+ socketPath: string,
+ tokenPath: string,
+ pidPath?: string,
+ launchNonce?: string
+ ): Promise => {
+ if ((pidPath === undefined) !== (launchNonce === undefined)) {
+ // Why: partial ownership metadata would launch a v24 daemon that cannot
+ // prove which PID record it may remove during self-retirement.
+ throw new Error('Daemon PID path and launch nonce must be provided together')
+ }
const entryPath = opts.getDaemonEntryPath()
- const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
- stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
- detached: true,
- env: { ...process.env },
- ...(process.platform === 'win32' ? { windowsHide: true } : {})
- })
+ const child = fork(
+ entryPath,
+ [
+ '--socket',
+ socketPath,
+ '--token',
+ tokenPath,
+ ...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : [])
+ ],
+ {
+ // Why: detached daemon output is not consumed; ignored streams cannot
+ // keep Electron alive after the child and IPC channel are unreferenced.
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
+ detached: true,
+ env: { ...process.env },
+ ...(process.platform === 'win32' ? { windowsHide: true } : {})
+ }
+ )
- await waitForReady(child)
+ let startedAtMs: number
+ try {
+ startedAtMs = await waitForReady(child)
+ } catch (error) {
+ return rejectAfterChildCleanup(child, error)
+ }
+ if (pidPath && launchNonce) {
+ if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) {
+ return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete'))
+ }
+ try {
+ writeFileSync(
+ pidPath,
+ serializeDaemonPidFile({
+ pid: child.pid as number,
+ startedAtMs,
+ entryPath,
+ launchNonce
+ }),
+ { mode: 0o600, flag: 'wx' }
+ )
+ } catch (error) {
+ return rejectAfterChildCleanup(child, error)
+ }
+ }
// Unref so the Electron process can exit without waiting for the daemon
child.unref()
@@ -30,7 +81,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
}
}
-function waitForReady(child: ChildProcess): Promise {
+function waitForReady(child: ChildProcess): Promise {
return new Promise((resolve, reject) => {
let timeout: ReturnType | undefined
let settled = false
@@ -42,15 +93,12 @@ function waitForReady(child: ChildProcess): Promise {
child.off('error', onError)
child.off('exit', onExit)
}
- function fail(error: Error, killChild = false): void {
+ function fail(error: Error): void {
if (settled) {
return
}
settled = true
cleanupStartupListeners()
- if (killChild) {
- child.kill('SIGTERM')
- }
reject(error)
}
function onMessage(msg: unknown): void {
@@ -58,11 +106,16 @@ function waitForReady(child: ChildProcess): Promise {
if (settled) {
return
}
+ const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs
+ if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) {
+ fail(new Error('Daemon readiness identity is incomplete'))
+ return
+ }
settled = true
// Why: the daemon is detached after readiness, so startup listeners
// must not keep the child process closure alive for the daemon lifetime.
cleanupStartupListeners()
- resolve()
+ resolve(startedAtMs)
}
}
function onError(err: Error): void {
@@ -73,7 +126,7 @@ function waitForReady(child: ChildProcess): Promise {
}
timeout = setTimeout(() => {
- fail(new Error('Daemon failed to signal readiness within timeout'), true)
+ fail(new Error('Daemon failed to signal readiness within timeout'))
}, READY_TIMEOUT_MS)
child.on('message', onMessage)
@@ -82,35 +135,88 @@ function waitForReady(child: ChildProcess): Promise {
})
}
-function shutdownChild(child: ChildProcess): Promise {
- return new Promise((resolve) => {
- if (child.killed) {
- resolve()
+async function shutdownChild(child: ChildProcess): Promise {
+ try {
+ if (
+ (child.exitCode !== null && child.exitCode !== undefined) ||
+ (child.signalCode !== null && child.signalCode !== undefined)
+ ) {
return
}
-
- let settled = false
- let timeout: ReturnType
- function finish(): void {
- if (settled) {
- return
+ await new Promise((resolve, reject) => {
+ let settled = false
+ let timeout: ReturnType
+ let forceTimeout: ReturnType | undefined
+ function finish(error?: unknown): void {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timeout)
+ if (forceTimeout) {
+ clearTimeout(forceTimeout)
+ }
+ child.off('exit', onExit)
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
}
- settled = true
- clearTimeout(timeout)
- child.off('exit', onExit)
- resolve()
+
+ function onExit(): void {
+ finish()
+ }
+
+ timeout = setTimeout(() => {
+ try {
+ if (child.kill('SIGKILL') === false) {
+ finish(new Error('Failed to deliver SIGKILL to daemon'))
+ return
+ }
+ } catch (error) {
+ finish(isNoSuchProcessError(error) ? undefined : error)
+ return
+ }
+ // Why: signal delivery is not process exit; keep the listener for one
+ // bounded interval before releasing launcher-owned handles.
+ if (!settled) {
+ forceTimeout = setTimeout(
+ () => finish(new Error('Daemon did not exit after SIGKILL')),
+ 1000
+ )
+ }
+ }, 5000)
+
+ child.once('exit', onExit)
+ try {
+ if (child.kill('SIGTERM') === false) {
+ finish(new Error('Failed to deliver SIGTERM to daemon'))
+ }
+ } catch (error) {
+ finish(isNoSuchProcessError(error) ? undefined : error)
+ }
+ })
+ } finally {
+ if (child.connected) {
+ child.disconnect()
}
+ child.unref()
+ }
+}
- function onExit(): void {
- finish()
- }
+async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise {
+ try {
+ await shutdownChild(child)
+ } catch (cleanupError) {
+ throw new AggregateError(
+ [launchError, cleanupError],
+ 'Daemon launch and child cleanup both failed'
+ )
+ }
+ throw launchError
+}
- timeout = setTimeout(() => {
- child.kill('SIGKILL')
- finish()
- }, 5000)
-
- child.once('exit', onExit)
- child.kill('SIGTERM')
- })
+function isNoSuchProcessError(error: unknown): boolean {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH'
}
diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts
index 63c5edc577b..0daaf0ac0cf 100644
--- a/src/main/daemon/types.ts
+++ b/src/main/daemon/types.ts
@@ -16,10 +16,11 @@ import type { TuiAgent } from '../../shared/types'
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
-export const PROTOCOL_VERSION = 23
+export const PROTOCOL_VERSION = 24
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
+export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
] as const
// ─── Session State Machine ──────────────────────────────────────────
@@ -80,19 +81,7 @@ export type { TerminalCheckpointFile } from './daemon-checkpoint-file'
// ─── NDJSON Protocol Messages ───────────────────────────────────────
// Hello handshake (first message on each socket)
-export type HelloMessage = {
- type: 'hello'
- version: number
- token: string
- clientId: string
- role: 'control' | 'stream'
-}
-
-export type HelloResponse = {
- type: 'hello'
- ok: boolean
- error?: string
-}
+export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol'
// ─── RPC Requests (Client → Daemon, on control socket) ─────────────
@@ -211,6 +200,11 @@ export type ListSessionsRequest = {
type: 'listSessions'
}
+export type ShutdownIfIdleRequest = {
+ id: string
+ type: 'shutdownIfIdle'
+}
+
export type DetachRequest = {
id: string
type: 'detach'
@@ -330,6 +324,7 @@ export type DaemonRequest =
| KillRequest
| SignalRequest
| ListSessionsRequest
+ | ShutdownIfIdleRequest
| DetachRequest
| GetCwdRequest
| GetForegroundProcessRequest
@@ -368,6 +363,10 @@ export type ListSessionsResult = {
sessions: SessionInfo[]
}
+export type ShutdownIfIdleResult = {
+ retiring: boolean
+}
+
export type SystemResolverHealth = 'healthy' | 'unhealthy' | 'unknown'
export type SystemResolverHealthResult = {
diff --git a/tests/e2e/daemon-lifecycle-retirement.spec.ts b/tests/e2e/daemon-lifecycle-retirement.spec.ts
new file mode 100644
index 00000000000..f522088998e
--- /dev/null
+++ b/tests/e2e/daemon-lifecycle-retirement.spec.ts
@@ -0,0 +1,241 @@
+import { fork, type ChildProcess } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { build } from 'esbuild'
+import { expect, test } from '@playwright/test'
+import { DaemonClient } from '../../src/main/daemon/client'
+import { DaemonPtyAdapter } from '../../src/main/daemon/daemon-pty-adapter'
+import {
+ getDaemonPidPath,
+ getDaemonSocketPath,
+ getDaemonTokenPath
+} from '../../src/main/daemon/daemon-spawner'
+import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
+
+type FixtureDaemon = {
+ child: ChildProcess
+ protocolVersion: number
+ socketPath: string
+ tokenPath: string
+ pidPath: string
+}
+
+async function waitFor(
+ label: string,
+ predicate: () => boolean | Promise,
+ timeoutMs = 10_000
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (!(await predicate())) {
+ if (Date.now() >= deadline) {
+ throw new Error(`Timed out waiting for ${label}`)
+ }
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ }
+}
+
+async function launchFixture(
+ entryPath: string,
+ daemonDir: string,
+ protocolVersion: number
+): Promise {
+ const socketPath = getDaemonSocketPath(daemonDir, protocolVersion)
+ const tokenPath = getDaemonTokenPath(daemonDir, protocolVersion)
+ const pidPath = getDaemonPidPath(daemonDir, protocolVersion)
+ const launchNonce = randomUUID()
+ const child = fork(
+ entryPath,
+ [
+ '--protocol',
+ String(protocolVersion),
+ '--socket',
+ socketPath,
+ '--token',
+ tokenPath,
+ ...(protocolVersion >= PROTOCOL_VERSION
+ ? ['--pid-record', pidPath, '--launch-nonce', launchNonce]
+ : [])
+ ],
+ {
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
+ env: {
+ ...process.env,
+ NODE_PATH: [path.join(process.cwd(), 'node_modules'), process.env.NODE_PATH]
+ .filter(Boolean)
+ .join(path.delimiter)
+ }
+ }
+ )
+ let stderr = ''
+ child.stderr?.on('data', (chunk) => {
+ stderr = `${stderr}${String(chunk)}`.slice(-8_192)
+ })
+ try {
+ await new Promise((resolve, reject) => {
+ const cleanup = (): void => {
+ clearTimeout(timeout)
+ child.off('message', onMessage)
+ child.off('error', onError)
+ child.off('exit', onExit)
+ }
+ const onMessage = (message: unknown): void => {
+ if ((message as { type?: unknown }).type !== 'ready') {
+ return
+ }
+ cleanup()
+ resolve()
+ }
+ const onError = (error: Error): void => {
+ cleanup()
+ reject(error)
+ }
+ const onExit = (code: number | null): void => {
+ cleanup()
+ reject(new Error(`Lifecycle fixture exited with ${code}: ${stderr.trim()}`))
+ }
+ const timeout = setTimeout(() => {
+ cleanup()
+ reject(new Error('Lifecycle fixture startup timed out'))
+ }, 10_000)
+ child.on('message', onMessage)
+ child.on('error', onError)
+ child.on('exit', onExit)
+ })
+ } catch (error) {
+ try {
+ await stopChild(child, `fixture v${protocolVersion}`)
+ } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'Fixture startup and cleanup both failed')
+ }
+ throw error
+ }
+ return { child, protocolVersion, socketPath, tokenPath, pidPath }
+}
+
+async function stopChild(child: ChildProcess, label: string): Promise {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return
+ }
+ child.kill('SIGTERM')
+ try {
+ await waitFor(
+ `${label} graceful exit`,
+ () => child.exitCode !== null || child.signalCode !== null,
+ 3_000
+ )
+ return
+ } catch {
+ child.kill('SIGKILL')
+ }
+ await waitFor(
+ `${label} forced exit`,
+ () => child.exitCode !== null || child.signalCode !== null,
+ 3_000
+ )
+}
+
+async function stopFixture(fixture: FixtureDaemon): Promise {
+ await stopChild(fixture.child, `fixture v${fixture.protocolVersion}`)
+}
+
+test('v22 stays reattachable while v24 retires after its last empty client disconnects', async () => {
+ const rootDir = mkdtempSync(path.join(tmpdir(), 'orca-daemon-lifecycle-'))
+ const daemonDir = path.join(rootDir, 'daemon')
+ mkdirSync(daemonDir, { recursive: true })
+ const entryPath = path.join(rootDir, 'daemon-lifecycle-entry.cjs')
+ const fixtures: FixtureDaemon[] = []
+ let testError: unknown
+
+ try {
+ await build({
+ entryPoints: [path.join(process.cwd(), 'tests/e2e/fixtures/daemon-lifecycle-entry.ts')],
+ outfile: entryPath,
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ target: 'node20',
+ external: ['node-pty'],
+ logLevel: 'silent'
+ })
+
+ const legacy = await launchFixture(entryPath, daemonDir, 22)
+ fixtures.push(legacy)
+ const legacyClient = new DaemonClient({
+ socketPath: legacy.socketPath,
+ tokenPath: legacy.tokenPath,
+ protocolVersion: 22
+ })
+ await legacyClient.ensureConnected()
+ await expect(
+ legacyClient.request('createOrAttach', { sessionId: 'legacy-live', cols: 80, rows: 24 })
+ ).resolves.toMatchObject({ isNew: true })
+ legacyClient.disconnect()
+
+ const current = await launchFixture(entryPath, daemonDir, PROTOCOL_VERSION)
+ fixtures.push(current)
+ const reattachClient = new DaemonClient({
+ socketPath: legacy.socketPath,
+ tokenPath: legacy.tokenPath,
+ protocolVersion: 22
+ })
+ await reattachClient.ensureConnected()
+ await expect(
+ reattachClient.request('createOrAttach', {
+ sessionId: 'legacy-live',
+ cols: 80,
+ rows: 24
+ })
+ ).resolves.toMatchObject({ isNew: false })
+ reattachClient.disconnect()
+
+ const currentAdapter = new DaemonPtyAdapter({
+ socketPath: current.socketPath,
+ tokenPath: current.tokenPath
+ })
+ const secondCurrentClient = new DaemonClient({
+ socketPath: current.socketPath,
+ tokenPath: current.tokenPath
+ })
+ await secondCurrentClient.ensureConnected()
+
+ // Why: failed adoption may overlap another authenticated app client, so
+ // only daemon-owned idle retirement can safely decide whether to exit.
+ await currentAdapter.disconnectOnly()
+ expect(current.child.exitCode).toBeNull()
+ await expect(secondCurrentClient.request('listSessions', undefined)).resolves.toEqual({
+ sessions: []
+ })
+ secondCurrentClient.disconnect()
+
+ await waitFor('v24 process exit', () => current.child.exitCode !== null)
+ expect(existsSync(current.tokenPath)).toBe(false)
+ expect(existsSync(current.pidPath)).toBe(false)
+ if (process.platform !== 'win32') {
+ expect(existsSync(current.socketPath)).toBe(false)
+ }
+ expect(legacy.child.exitCode).toBeNull()
+ } catch (error) {
+ testError = error
+ }
+
+ const results = await Promise.allSettled(fixtures.map((fixture) => stopFixture(fixture)))
+ const cleanupErrors = results.flatMap((result) =>
+ result.status === 'rejected' ? [result.reason] : []
+ )
+ try {
+ rmSync(rootDir, { recursive: true, force: true })
+ } catch (error) {
+ cleanupErrors.push(error)
+ }
+ if (testError !== undefined && cleanupErrors.length > 0) {
+ throw new AggregateError([testError, ...cleanupErrors], 'Lifecycle test and cleanup failed')
+ }
+ if (testError !== undefined) {
+ throw testError
+ }
+ if (cleanupErrors.length > 0) {
+ throw new AggregateError(cleanupErrors, 'Lifecycle fixture cleanup failed')
+ }
+})
diff --git a/tests/e2e/fixtures/daemon-lifecycle-entry.ts b/tests/e2e/fixtures/daemon-lifecycle-entry.ts
new file mode 100644
index 00000000000..0e09897b0c0
--- /dev/null
+++ b/tests/e2e/fixtures/daemon-lifecycle-entry.ts
@@ -0,0 +1,122 @@
+import process from 'node:process'
+import { writeFileSync } from 'node:fs'
+import { startDaemon, type DaemonHandle } from '../../../src/main/daemon/daemon-main'
+import { serializeDaemonPidFile } from '../../../src/main/daemon/daemon-spawner'
+import type { SubprocessHandle } from '../../../src/main/daemon/session'
+
+type FixtureArgs = {
+ protocolVersion: number
+ socketPath: string
+ tokenPath: string
+ pidPath?: string
+ launchNonce?: string
+}
+
+function parseArgs(argv: string[]): FixtureArgs {
+ const values = new Map()
+ for (let index = 0; index < argv.length; index += 2) {
+ const key = argv[index]
+ const value = argv[index + 1]
+ if (!key || !value) {
+ throw new Error('Lifecycle fixture arguments must be key/value pairs')
+ }
+ values.set(key, value)
+ }
+ const protocolVersion = Number(values.get('--protocol'))
+ const socketPath = values.get('--socket')
+ const tokenPath = values.get('--token')
+ const pidPath = values.get('--pid-record')
+ const launchNonce = values.get('--launch-nonce')
+ if (
+ !Number.isInteger(protocolVersion) ||
+ protocolVersion < 1 ||
+ !socketPath ||
+ !tokenPath ||
+ Boolean(pidPath) !== Boolean(launchNonce)
+ ) {
+ throw new Error('Invalid lifecycle fixture arguments')
+ }
+ return {
+ protocolVersion,
+ socketPath,
+ tokenPath,
+ ...(pidPath && launchNonce ? { pidPath, launchNonce } : {})
+ }
+}
+
+function createFixtureSubprocess(): SubprocessHandle {
+ let onData: ((data: string) => void) | null = null
+ let onExit: ((code: number) => void) | null = null
+ let exited = false
+ const exit = (code: number): void => {
+ if (exited) {
+ return
+ }
+ exited = true
+ onExit?.(code)
+ }
+ return {
+ pid: process.pid,
+ getForegroundProcess: () => null,
+ write: (data) => onData?.(data),
+ resize: () => {},
+ kill: () => exit(0),
+ forceKill: () => exit(137),
+ signal: () => {},
+ onData: (callback) => {
+ onData = callback
+ },
+ onExit: (callback) => {
+ onExit = callback
+ },
+ dispose: () => exit(0)
+ }
+}
+
+async function main(): Promise {
+ const args = parseArgs(process.argv.slice(2))
+ const startedAtMs = Date.now() - process.uptime() * 1000
+ let daemon: DaemonHandle | null = await startDaemon({
+ protocolVersion: args.protocolVersion,
+ socketPath: args.socketPath,
+ tokenPath: args.tokenPath,
+ ...(args.pidPath ? { pidPath: args.pidPath } : {}),
+ ...(args.launchNonce ? { launchNonce: args.launchNonce } : {}),
+ startedAtMs,
+ spawnSubprocess: () => createFixtureSubprocess(),
+ onIdleShutdown: () => process.exit(0)
+ })
+ if (args.pidPath && args.launchNonce) {
+ writeFileSync(
+ args.pidPath,
+ serializeDaemonPidFile({
+ pid: process.pid,
+ startedAtMs,
+ launchNonce: args.launchNonce
+ }),
+ { mode: 0o600, flag: 'wx' }
+ )
+ }
+
+ let shuttingDown = false
+ const shutdown = async (): Promise => {
+ if (shuttingDown) {
+ return
+ }
+ shuttingDown = true
+ try {
+ await daemon?.shutdown()
+ daemon = null
+ } finally {
+ process.exit(0)
+ }
+ }
+ process.on('SIGTERM', () => void shutdown())
+ process.on('SIGINT', () => void shutdown())
+ process.send?.({ type: 'ready', pid: process.pid, startedAtMs })
+}
+
+void main().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
From d628c9300eae658497435bcbeabebad7566883cb Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Sun, 19 Jul 2026 22:54:39 -0400
Subject: [PATCH 02/35] fix(terminal): harden revealed inline-TUI convergence
(#9503)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
.../terminal-pane/pty-connection.test.ts | 256 ++++++-
.../terminal-pane/pty-connection.ts | 72 +-
.../components/terminal-pane/replay-guard.ts | 3 +
.../pane-fit-continuation-retry.ts | 87 +++
.../src/lib/pane-manager/pane-fit.test.ts | 169 +++++
src/renderer/src/lib/pane-manager/pane-fit.ts | 56 +-
...-terminal-foreground-render-settle.test.ts | 83 ++
.../pane-terminal-foreground-render-settle.ts | 33 +-
.../codex-inline-live-block-fixture.cjs | 139 ++++
...inal-inline-tui-reveal-convergence.spec.ts | 718 ++++++++++++++++++
10 files changed, 1597 insertions(+), 19 deletions(-)
create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts
create mode 100644 src/renderer/src/lib/pane-manager/pane-fit.test.ts
create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts
create mode 100644 tests/e2e/fixtures/codex-inline-live-block-fixture.cjs
create mode 100644 tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts
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 1c9323dd0a9..3f05c9a5371 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -5953,7 +5953,8 @@ describe('connectPanePty', () => {
reattach.resolve()
await flushAsyncTicks()
- expect(transport.resize).toHaveBeenLastCalledWith(65, 63)
+ expect(transport.resize).toHaveBeenCalledWith(65, 63)
+ expect(transport.resize).toHaveBeenLastCalledWith(65, 63, { claim: true })
})
it('adopts a live eager PTY and withholds snapshots after its renderer dies', async () => {
@@ -7404,7 +7405,10 @@ describe('connectPanePty', () => {
const blankViewport = buildFreshShellViewportBlankingSequence(destinationRows)
expect(pane.terminal.resize).toHaveBeenCalledWith(recoveredCols, recoveredRows)
expect(transport.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows)
- expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows)
+ expect(transport.resize).toHaveBeenCalledWith(destinationCols, destinationRows)
+ expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows, {
+ claim: true
+ })
expect(written).toContain(viewportClear)
expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect(written).toEqual(
@@ -8617,6 +8621,161 @@ describe('connectPanePty', () => {
expect(liveIndex).toBeGreaterThan(snapshotIndex)
})
+ it('re-enforces follow intent after deferred reattach live output parses', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent')
+ const transport = createMockTransport('tab-pty')
+ transport.connect.mockImplementation(
+ async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
+ if (!sessionId) {
+ return null
+ }
+ callbacks?.onData?.('post-snapshot-live')
+ return { id: sessionId, snapshot: 'authoritative-snapshot' }
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const pane = createPane(1)
+ pane.terminal.buffer.active.baseY = 100
+ pane.terminal.buffer.active.viewportY = 100
+ markTerminalFollowOutput(pane.terminal)
+ const parseCallbacks: (() => void)[] = []
+ pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
+ parseCallbacks.push(() => {
+ if (data === 'post-snapshot-live') {
+ pane.terminal.buffer.active.baseY = 200
+ pane.terminal.buffer.active.viewportY = 100
+ }
+ callback?.()
+ })
+ })
+ const deps = createDeps({
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks(20)
+ for (let index = 0; index < 30; index += 1) {
+ parseCallbacks.shift()?.()
+ await flushAsyncTicks(4)
+ if (parseCallbacks.length === 0 && index > 5) {
+ break
+ }
+ }
+
+ expect(pane.terminal.buffer.active.viewportY).toBe(200)
+ })
+
+ it('does not steal a newer user pin while deferred reattach output settles', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { markTerminalFollowOutput, markTerminalPinnedViewport } =
+ await import('@/lib/pane-manager/terminal-scroll-intent')
+ const transport = createMockTransport('tab-pty')
+ transport.connect.mockImplementation(
+ async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
+ if (!sessionId) {
+ return null
+ }
+ callbacks?.onData?.('post-snapshot-live')
+ return { id: sessionId, snapshot: 'authoritative-snapshot' }
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const pane = createPane(1)
+ pane.terminal.buffer.active.baseY = 100
+ pane.terminal.buffer.active.viewportY = 100
+ markTerminalFollowOutput(pane.terminal)
+ const parseCallbacks: { data: string; run: () => void }[] = []
+ pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
+ parseCallbacks.push({
+ data,
+ run: () => {
+ if (data === 'post-snapshot-live') {
+ pane.terminal.buffer.active.baseY = 200
+ pane.terminal.buffer.active.viewportY = 100
+ }
+ callback?.()
+ }
+ })
+ })
+ const deps = createDeps({
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks(20)
+ for (let index = 0; index < 40; index += 1) {
+ const pending = parseCallbacks.shift()
+ pending?.run()
+ await flushAsyncTicks(4)
+ if (pending?.data === 'post-snapshot-live') {
+ pane.terminal.buffer.active.viewportY = 150
+ markTerminalPinnedViewport(pane.terminal)
+ }
+ if (parseCallbacks.length === 0 && index > 8) {
+ break
+ }
+ }
+
+ expect(pane.terminal.buffer.active.viewportY).toBe(150)
+ })
+
+ it('does not enforce a deferred viewport after the pane becomes hidden', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent')
+ const transport = createMockTransport('tab-pty')
+ transport.connect.mockImplementation(
+ async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
+ if (!sessionId) {
+ return null
+ }
+ callbacks?.onData?.('post-snapshot-live')
+ return { id: sessionId, snapshot: 'authoritative-snapshot' }
+ }
+ )
+ transportFactoryQueue.push(transport)
+ const pane = createPane(1)
+ pane.terminal.buffer.active.baseY = 100
+ pane.terminal.buffer.active.viewportY = 100
+ markTerminalFollowOutput(pane.terminal)
+ const parseCallbacks: { data: string; run: () => void }[] = []
+ pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
+ parseCallbacks.push({
+ data,
+ run: () => {
+ if (data === 'post-snapshot-live') {
+ pane.terminal.buffer.active.baseY = 200
+ pane.terminal.buffer.active.viewportY = 100
+ }
+ callback?.()
+ }
+ })
+ })
+ const deps = createDeps({
+ isVisibleRef: { current: true },
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
+ })
+
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks(20)
+ for (let index = 0; index < 40; index += 1) {
+ const pending = parseCallbacks.shift()
+ pending?.run()
+ await flushAsyncTicks(4)
+ if (pending?.data === 'post-snapshot-live') {
+ ;(deps.isVisibleRef as { current: boolean }).current = false
+ }
+ if (parseCallbacks.length === 0 && index > 8) {
+ break
+ }
+ }
+
+ expect(pane.terminal.buffer.active.viewportY).toBe(100)
+ })
+
it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
@@ -11777,6 +11936,99 @@ describe('connectPanePty', () => {
disposable.dispose()
})
+ it('slices abandoned pending chunks against a replay that already painted', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-id')
+ const capturedDataCallback: {
+ current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
+ } = { current: null }
+ transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-id'
+ })
+ transportFactoryQueue.push(transport)
+ const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
+ typeof vi.fn
+ >
+ const snapshot = createDeferred<{
+ data: string
+ cols: number
+ rows: number
+ seq: number
+ pendingDeliveryStartSeq: number
+ }>()
+ getMainBufferSnapshot.mockReturnValue(snapshot.promise)
+ const hidden = 'hidden-codex-output\r\n'
+ const coveredLive = 'LIVE_DUP_LINE\r\n'
+ const afterAbandon = 'AFTER_ABANDON\r\n'
+
+ const pane = createPane(1)
+ const manager = createManager(1)
+ const deps = createDeps({
+ isVisibleRef: { current: false },
+ startup: { command: 'codex' }
+ })
+ const disposable = connectPanePty(pane as never, manager as never, deps as never)
+ await flushAsyncTicks(6)
+ expect(capturedDataCallback.current).not.toBeNull()
+
+ vi.useFakeTimers()
+ capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
+ ;(deps.isVisibleRef as { current: boolean }).current = true
+ capturedDataCallback.current?.(coveredLive, {
+ seq: hidden.length + coveredLive.length,
+ rawLength: coveredLive.length
+ })
+ await flushAsyncTicks(4)
+
+ const heldCallbacks: (() => void)[] = []
+ pane.terminal.write.mockImplementation(function write(
+ data: string,
+ callback?: () => void
+ ): void {
+ if (callback) {
+ heldCallbacks.push(callback)
+ }
+ void data
+ })
+ snapshot.resolve({
+ data: 'SNAP_STATE\r\n',
+ cols: 100,
+ rows: 30,
+ seq: hidden.length + coveredLive.length,
+ pendingDeliveryStartSeq: 0
+ })
+ await flushAsyncTicks(6)
+
+ vi.advanceTimersByTime(750)
+ await flushAsyncTicks(10)
+
+ const writtenAfterAbandon = pane.terminal.write.mock.calls.map(([data]) => data as string)
+ expect(writtenAfterAbandon.join('')).not.toContain('LIVE_DUP_LINE')
+
+ pane.terminal.write.mockClear()
+ capturedDataCallback.current?.(coveredLive, {
+ seq: hidden.length + coveredLive.length,
+ rawLength: coveredLive.length
+ })
+ await flushAsyncTicks(4)
+ expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).not.toContain(
+ 'LIVE_DUP_LINE'
+ )
+
+ capturedDataCallback.current?.(afterAbandon, {
+ seq: hidden.length + coveredLive.length + afterAbandon.length,
+ rawLength: afterAbandon.length
+ })
+ await flushAsyncTicks(4)
+ expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).toContain(
+ 'AFTER_ABANDON'
+ )
+
+ heldCallbacks.forEach((callback) => callback())
+ disposable.dispose()
+ })
+
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 17fb6225ba3..18066ae75d2 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -131,6 +131,7 @@ import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
import {
+ enforceTerminalCurrentScrollIntent,
getTerminalScrollIntentKind,
markTerminalFollowOutput
} from '@/lib/pane-manager/terminal-scroll-intent'
@@ -4907,7 +4908,8 @@ export function connectPanePty(
// scheduler's deferred drain cannot land older bytes on top of the replay.
flushTerminalOutput(pane.terminal)
replayIntoTerminal(pane, deps.replayingPanesRef, data, {
- shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
+ shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
+ shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
}
@@ -4916,7 +4918,8 @@ export function connectPanePty(
// merely after the write was queued.
flushTerminalOutput(pane.terminal)
return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, {
- shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
+ shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
+ shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
}
@@ -5215,6 +5218,12 @@ export function connectPanePty(
let hiddenOutputRestoreGeneration = 0
// Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS).
let hiddenOutputRestoreFloodSuppressedUntil = 0
+ // Why: queued replay writes still paint after deadline abandonment; the
+ // fallback drain must not write snapshot-covered live bytes a second time.
+ let hiddenOutputRestoreReplayingSnapshot: {
+ seq?: number
+ pendingDeliveryStartSeq?: number
+ } | null = null
let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null
// Why: after a snapshot restore, main can still drain ACK-backlog chunks
// whose bytes the snapshot already covers — writing them unguarded
@@ -6311,6 +6320,8 @@ export function connectPanePty(
? []
: hiddenOutputRestorePendingChunks.slice()
const hadPendingOverflow = hiddenOutputRestorePendingOverflow
+ const replayingSnapshot = hiddenOutputRestoreReplayingSnapshot
+ hiddenOutputRestoreReplayingSnapshot = null
hiddenOutputRestoreGeneration += 1
if (
hiddenOutputSnapshotScrollRestore?.valid &&
@@ -6346,7 +6357,21 @@ export function connectPanePty(
if (hadPendingOverflow) {
return
}
- const pendingData = pendingChunks.map((chunk) => chunk.data).join('')
+ const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null
+ let pendingData = ''
+ for (const chunk of pendingChunks) {
+ const sliced =
+ replayedSeq === null ? chunk.data : getChunkDataAfterSnapshot(chunk, replayedSeq)
+ pendingData += sliced ?? chunk.data
+ }
+ if (replayingSnapshot && replayedSeq !== null) {
+ setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot)
+ for (const chunk of pendingChunks) {
+ if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) {
+ restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq)
+ }
+ }
+ }
if (pendingData) {
writePtyOutputToXterm(pendingData, true)
}
@@ -6391,6 +6416,7 @@ export function connectPanePty(
resetHiddenRendererRiskState()
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
+ hiddenOutputRestoreReplayingSnapshot = null
hiddenOutputRestoreGeneration += 1
}
@@ -6482,6 +6508,7 @@ export function connectPanePty(
cols: number
rows: number
seq?: number
+ pendingDeliveryStartSeq?: number
alternateScreen?: boolean
scrollbackAnsi?: string
pendingEscapeTailAnsi?: string
@@ -6517,6 +6544,14 @@ export function connectPanePty(
return
}
scrollRestore.started = true
+ if (typeof snapshot.seq === 'number') {
+ hiddenOutputRestoreReplayingSnapshot = {
+ seq: snapshot.seq,
+ ...(typeof snapshot.pendingDeliveryStartSeq === 'number'
+ ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq }
+ : {})
+ }
+ }
discardTerminalOutput(pane.terminal)
if (
hasSnapshotDimensions &&
@@ -6613,7 +6648,7 @@ export function connectPanePty(
}
}
},
- { shouldContinue: isCurrentRestore }
+ { shouldContinue: isCurrentRestore, retryIfUnmeasurable: true }
)
pendingHiddenSnapshotFit = fit
try {
@@ -6783,6 +6818,7 @@ export function connectPanePty(
// still draining from main's ACK backlog below that point are
// duplicates the dataCallback reconciliation must suppress.
setRestoredSnapshotBaseline(currentPtyId, snapshot)
+ hiddenOutputRestoreReplayingSnapshot = null
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
hiddenOutputRestoreFreshSnapshotNeeded = false
const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq)
@@ -7162,6 +7198,7 @@ export function connectPanePty(
// Why: createOrAttach snapshots precede bytes emitted before its IPC
// reply. Paint the authoritative replay first, then admit those live
// chunks so the replay clear cannot erase newer output.
+ let deliveredDeferredChunks = 0
for (const chunk of chunks) {
if (
chunk.ptyId !== currentPtyId ||
@@ -7171,6 +7208,23 @@ export function connectPanePty(
continue
}
dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
+ deliveredDeferredChunks += 1
+ }
+ if (deliveredDeferredChunks > 0) {
+ // Why: replay restores the viewport before these newer bytes parse;
+ // settle the bounded deferred slice, then apply the latest user intent.
+ flushTerminalOutput(pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS })
+ void waitForTerminalReplayWritesParsed(pane.terminal).then(() => {
+ if (
+ disposed ||
+ !deps.isVisibleRef.current ||
+ transport.getPtyId() !== currentPtyId ||
+ transportStreamGeneration !== currentGeneration
+ ) {
+ return
+ }
+ enforceTerminalCurrentScrollIntent(pane.terminal)
+ })
}
}
@@ -7472,16 +7526,22 @@ export function connectPanePty(
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
},
- { shouldContinue: isCurrentReattachPayload }
+ { shouldContinue: isCurrentReattachPayload, retryIfUnmeasurable: true }
)
pendingReattachFit = fit
+ let fitCompleted = false
try {
- await fit.completion
+ fitCompleted = await fit.completion
} finally {
if (pendingReattachFit === fit) {
pendingReattachFit = null
}
}
+ if (fitCompleted && isCurrentReattachPayload() && deps.isVisibleRef.current) {
+ // Why: reattach resize is fire-and-forget; verify the provider's
+ // applied grid while this reveal still owns the visible pane.
+ ptySizeReassertion.request({ fit: false })
+ }
} else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) {
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts
index 6711390d851..b7e43ff82cb 100644
--- a/src/renderer/src/components/terminal-pane/replay-guard.ts
+++ b/src/renderer/src/components/terminal-pane/replay-guard.ts
@@ -64,6 +64,7 @@ const REPLAY_GUARD_STALL_CHECK_MS = 10_000
type ReplayTerminalOptions = {
shouldRefreshViewportSynchronously?: () => boolean
+ shouldReleaseRenderPause?: () => boolean
stallCheckMs?: number
}
@@ -208,6 +209,7 @@ export function replayIntoTerminal(
forceViewportRefresh: true,
followupViewportRefresh: true,
shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously,
+ shouldReleaseRenderPause: options.shouldReleaseRenderPause,
onParsed: guardCallbacks.onParsed,
onWriteFailure: guardCallbacks.onWriteFailure
})
@@ -242,6 +244,7 @@ export function replayIntoTerminalAsync(
forceViewportRefresh: true,
followupViewportRefresh: true,
shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously,
+ shouldReleaseRenderPause: options.shouldReleaseRenderPause,
onParsed: guardCallbacks.onParsed,
onWriteFailure: guardCallbacks.onWriteFailure
})
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
new file mode 100644
index 00000000000..8567ad44884
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts
@@ -0,0 +1,87 @@
+import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
+import type { ManagedPane } from './pane-manager-types'
+
+const MAX_RETRY_FRAMES = 40
+const LAYOUT_SETTLE_MS = 16
+
+type RetrySchedule = { cancel: () => void }
+
+type RetryState = {
+ attempts: number
+ schedule: RetrySchedule | null
+ retry: () => boolean
+ onExhausted: () => void
+}
+
+const retryByPane = new WeakMap()
+
+function scheduleRetryTick(run: () => void): RetrySchedule {
+ if (typeof requestAnimationFrame === 'function') {
+ let cancelled = false
+ let timer: ReturnType | null = null
+ const rafId = requestAnimationFrame(() => {
+ if (!cancelled) {
+ // 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)
+ }
+ })
+ return {
+ cancel: () => {
+ cancelled = true
+ if (typeof cancelAnimationFrame === 'function') {
+ cancelAnimationFrame(rafId)
+ }
+ if (timer !== null) {
+ clearTimeout(timer)
+ }
+ }
+ }
+ }
+ const timer = setTimeout(run, LAYOUT_SETTLE_MS)
+ return { cancel: () => clearTimeout(timer) }
+}
+
+export function clearPaneFitContinuationRetry(pane: ManagedPane): void {
+ const state = retryByPane.get(pane)
+ if (!state) {
+ return
+ }
+ retryByPane.delete(pane)
+ state.schedule?.cancel()
+ state.schedule = null
+}
+
+export function armPaneFitContinuationRetry(
+ pane: ManagedPane,
+ callbacks: { retry: () => boolean; onExhausted: () => void }
+): void {
+ const state = retryByPane.get(pane) ?? {
+ attempts: 0,
+ schedule: null,
+ ...callbacks
+ }
+ state.retry = callbacks.retry
+ state.onExhausted = callbacks.onExhausted
+ retryByPane.set(pane, state)
+ if (state.schedule) {
+ return
+ }
+ state.schedule = scheduleRetryTick(() => {
+ state.schedule = null
+ if (state.retry()) {
+ clearPaneFitContinuationRetry(pane)
+ return
+ }
+ state.attempts += 1
+ if (state.attempts >= MAX_RETRY_FRAMES) {
+ recordRendererCrashBreadcrumb('terminal_safe_fit_retry_exhausted', {
+ paneId: pane.id
+ })
+ clearPaneFitContinuationRetry(pane)
+ state.onExhausted()
+ return
+ }
+ armPaneFitContinuationRetry(pane, state)
+ })
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts
new file mode 100644
index 00000000000..5857349e3a6
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts
@@ -0,0 +1,169 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
+import type { ManagedPane, ScrollState } from './pane-manager-types'
+import { safeFit, safeFitAndThen } from './pane-fit'
+
+vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
+ recordRendererCrashBreadcrumb: vi.fn()
+}))
+
+let nextRafId = 1
+let pendingRafs = new Map()
+
+function flushAnimationFrames(timestamp = 16): void {
+ const callbacks = Array.from(pendingRafs.values())
+ pendingRafs = new Map()
+ for (const callback of callbacks) {
+ callback(timestamp)
+ }
+}
+
+function createPane(options: {
+ rect: { width: number; height: number }
+ proposed?: () => { cols: number; rows: number } | undefined
+}): ManagedPane & { setRect: (rect: { width: number; height: number }) => void } {
+ let rect = options.rect
+ const leafId = '22222222-2222-4222-8222-222222222222'
+ const pane = {
+ id: 7,
+ leafId,
+ stablePaneId: leafId,
+ terminal: { cols: 80, rows: 24 },
+ container: {
+ dataset: {},
+ getBoundingClientRect: () => ({ width: rect.width, height: rect.height })
+ },
+ xtermContainer: {
+ getBoundingClientRect: () => ({ width: rect.width, height: rect.height })
+ },
+ fitAddon: {
+ fit: vi.fn(),
+ proposeDimensions: vi.fn(options.proposed ?? (() => ({ cols: 132, rows: 40 })))
+ },
+ serializeAddon: {},
+ searchAddon: {},
+ pendingSplitScrollState: null as ScrollState | null,
+ setRect: (next: { width: number; height: number }) => {
+ rect = next
+ }
+ }
+ return pane as unknown as ManagedPane & {
+ setRect: (rect: { width: number; height: number }) => void
+ }
+}
+
+describe('safeFitAndThen unmeasurable-pane retry', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ nextRafId = 1
+ pendingRafs = new Map()
+ vi.stubGlobal(
+ 'requestAnimationFrame',
+ vi.fn((callback: FrameRequestCallback) => {
+ const id = nextRafId++
+ pendingRafs.set(id, callback)
+ return id
+ })
+ )
+ vi.stubGlobal(
+ 'cancelAnimationFrame',
+ vi.fn((id: number) => {
+ pendingRafs.delete(id)
+ })
+ )
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('runs the continuation once reveal layout becomes measurable', async () => {
+ const pane = createPane({ rect: { width: 0, height: 0 } })
+ const continuation = vi.fn()
+
+ const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
+ retryIfUnmeasurable: true
+ })
+ pane.setRect({ width: 800, height: 600 })
+ flushAnimationFrames()
+ vi.advanceTimersByTime(16)
+
+ expect(continuation).toHaveBeenCalledTimes(1)
+ await expect(handle.completion).resolves.toBe(true)
+ })
+
+ it('cancels its scheduled frame with the continuation', async () => {
+ const pane = createPane({ rect: { width: 0, height: 0 } })
+ const continuation = vi.fn()
+
+ const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
+ retryIfUnmeasurable: true
+ })
+ handle.cancel()
+ pane.setRect({ width: 800, height: 600 })
+ flushAnimationFrames()
+
+ expect(continuation).not.toHaveBeenCalled()
+ await expect(handle.completion).resolves.toBe(false)
+ })
+
+ it('does not retry a stale restore', async () => {
+ const pane = createPane({ rect: { width: 0, height: 0 } })
+ const continuation = vi.fn()
+ let current = true
+
+ const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
+ shouldContinue: () => current,
+ retryIfUnmeasurable: true
+ })
+ current = false
+ pane.setRect({ width: 800, height: 600 })
+ flushAnimationFrames()
+ vi.advanceTimersByTime(16)
+
+ expect(continuation).not.toHaveBeenCalled()
+ await expect(handle.completion).resolves.toBe(false)
+ })
+
+ it('still flushes through an ordinary external fit', async () => {
+ const pane = createPane({ rect: { width: 0, height: 0 } })
+ const continuation = vi.fn()
+
+ const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation)
+ pane.setRect({ width: 800, height: 600 })
+ flushAnimationFrames()
+ vi.advanceTimersByTime(16)
+ expect(continuation).not.toHaveBeenCalled()
+
+ safeFit(pane)
+
+ expect(continuation).toHaveBeenCalledTimes(1)
+ await expect(handle.completion).resolves.toBe(true)
+ })
+
+ it('resolves failure after the bounded frame budget instead of hanging reattach', async () => {
+ const pane = createPane({ rect: { width: 0, height: 0 } })
+ const continuation = vi.fn()
+
+ const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
+ retryIfUnmeasurable: true
+ })
+ for (let frame = 0; frame < 40; frame += 1) {
+ flushAnimationFrames(frame * 16)
+ vi.advanceTimersByTime(16)
+ }
+
+ expect(continuation).not.toHaveBeenCalled()
+ expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
+ 'terminal_safe_fit_retry_exhausted',
+ { paneId: 7 }
+ )
+ await expect(handle.completion).resolves.toBe(false)
+
+ pane.setRect({ width: 800, height: 600 })
+ safeFit(pane)
+ expect(continuation).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts
index 573ba6ab7ed..e555a478031 100644
--- a/src/renderer/src/lib/pane-manager/pane-fit.ts
+++ b/src/renderer/src/lib/pane-manager/pane-fit.ts
@@ -1,5 +1,9 @@
import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types'
import { getFitOverrideForPty } from './mobile-fit-overrides'
+import {
+ armPaneFitContinuationRetry,
+ clearPaneFitContinuationRetry
+} from './pane-fit-continuation-retry'
import {
captureTerminalStructuralScrollIntent,
isTerminalStructuralScrollIntentCurrent,
@@ -163,6 +167,7 @@ function settlePendingSafeFitContinuation(
operations.delete(operationKey)
if (operations.size === 0) {
pendingSafeFitContinuations.delete(pane)
+ clearPaneFitContinuationRetry(pane)
}
pending.resolve(completed)
}
@@ -192,11 +197,52 @@ export function safeFit(pane: ManagedPane): boolean {
// Why: replay transactions may be waiting for renderer dimensions; any
// successful ordinary fit is the event that makes their PTY grid authoritative.
flushPendingSafeFitContinuations(pane)
+ clearPaneFitContinuationRetry(pane)
}
return completed
}
+function pruneStaleSafeFitContinuations(pane: ManagedPane): void {
+ const operations = pendingSafeFitContinuations.get(pane)
+ if (!operations) {
+ return
+ }
+ for (const [operationKey, pending] of operations) {
+ if (!pending.shouldContinue()) {
+ settlePendingSafeFitContinuation(pane, operationKey, pending, false)
+ }
+ }
+}
+
+function failPendingSafeFitContinuations(pane: ManagedPane): void {
+ const operations = pendingSafeFitContinuations.get(pane)
+ if (!operations) {
+ return
+ }
+ for (const [operationKey, pending] of Array.from(operations.entries())) {
+ settlePendingSafeFitContinuation(pane, operationKey, pending, false)
+ }
+}
+
+function armSafeFitContinuationRetry(pane: ManagedPane): void {
+ armPaneFitContinuationRetry(pane, {
+ retry: () => {
+ pruneStaleSafeFitContinuations(pane)
+ if (!pendingSafeFitContinuations.get(pane)?.size) {
+ return true
+ }
+ return safeFit(pane)
+ },
+ onExhausted: () => {
+ // Why: a reveal transaction must degrade after its bounded layout wait;
+ // leaving completion pending forever blocks deferred output release.
+ failPendingSafeFitContinuations(pane)
+ }
+ })
+}
+
export function cancelPendingSafeFitContinuations(pane: ManagedPane): void {
+ clearPaneFitContinuationRetry(pane)
const operations = pendingSafeFitContinuations.get(pane)
if (!operations) {
return
@@ -213,7 +259,7 @@ export function safeFitAndThen(
pane: ManagedPane,
operationKey: string,
continuation: () => void,
- options: { shouldContinue?: () => boolean } = {}
+ options: { shouldContinue?: () => boolean; retryIfUnmeasurable?: boolean } = {}
): SafeFitContinuationHandle {
const operations = pendingSafeFitContinuations.get(pane) ?? new Map()
const replaced = operations.get(operationKey)
@@ -245,13 +291,17 @@ export function safeFitAndThen(
`safe-fit-and-then:${operationKey}`,
() => {
if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) {
- safeFit(pane)
+ if (!safeFit(pane) && options.retryIfUnmeasurable) {
+ armSafeFitContinuationRetry(pane)
+ }
}
}
)
) {
return { completion, cancel }
}
- safeFit(pane)
+ if (!safeFit(pane) && options.retryIfUnmeasurable) {
+ armSafeFitContinuationRetry(pane)
+ }
return { completion, cancel }
}
diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts
new file mode 100644
index 00000000000..cb62cd15fb7
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest'
+import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle'
+
+type RefreshFn = (start: number, end: number, sync?: boolean) => void
+
+type RenderServiceStub = {
+ _isPaused: boolean
+ _needsFullRefresh: boolean
+ refreshRows: ReturnType>
+}
+
+function createTerminal(paused: boolean): {
+ terminal: {
+ rows: number
+ buffer: { active: { cursorY: number; baseY: number; viewportY: number } }
+ _core: { refresh: ReturnType>; _renderService: RenderServiceStub }
+ refresh: ReturnType void>>
+ write: (data: string, callback?: () => void) => void
+ }
+ renderService: RenderServiceStub
+} {
+ const renderService: RenderServiceStub = {
+ _isPaused: paused,
+ _needsFullRefresh: paused,
+ refreshRows: vi.fn()
+ }
+ const terminal = {
+ rows: 24,
+ buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } },
+ _core: { refresh: vi.fn(), _renderService: renderService },
+ refresh: vi.fn<(start: number, end: number) => void>(),
+ write: (_data: string, callback?: () => void) => callback?.()
+ }
+ return { terminal, renderService }
+}
+
+describe('writeForegroundTerminalChunk render-pause ownership', () => {
+ it('drives a paused render only for a currently visible reveal replay', () => {
+ const { terminal, renderService } = createTerminal(true)
+
+ writeForegroundTerminalChunk(terminal, 'replayed snapshot bytes', {
+ forceViewportRefresh: true,
+ shouldReleaseRenderPause: () => true
+ })
+
+ expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
+ expect(renderService._isPaused).toBe(false)
+ expect(renderService._needsFullRefresh).toBe(false)
+ })
+
+ it('leaves a newly-hidden terminal paused when replay parsing finishes', () => {
+ const { terminal, renderService } = createTerminal(true)
+ let parsed: (() => void) | undefined
+ let visible = true
+ terminal.write = (_data: string, callback?: () => void) => {
+ parsed = callback
+ }
+
+ writeForegroundTerminalChunk(terminal, 'late replay bytes', {
+ forceViewportRefresh: true,
+ shouldReleaseRenderPause: () => visible
+ })
+ visible = false
+ parsed?.()
+
+ expect(renderService.refreshRows).not.toHaveBeenCalled()
+ expect(renderService._isPaused).toBe(true)
+ expect(renderService._needsFullRefresh).toBe(true)
+ })
+
+ it('does not inspect RenderService on the ordinary forced-refresh path', () => {
+ const { terminal, renderService } = createTerminal(true)
+ const renderServiceRead = vi.fn(() => renderService)
+ Object.defineProperty(terminal._core, '_renderService', { get: renderServiceRead })
+
+ writeForegroundTerminalChunk(terminal, 'ordinary output', {
+ forceViewportRefresh: true
+ })
+
+ expect(renderServiceRead).not.toHaveBeenCalled()
+ expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true)
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts
index 4819212adb1..508fb7e1240 100644
--- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts
+++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts
@@ -1,3 +1,4 @@
+import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard'
export type ForegroundTerminalOutputTarget = {
@@ -20,6 +21,7 @@ type ForegroundTerminalWriteOptions = {
forceViewportRefresh?: boolean
followupViewportRefresh?: boolean
shouldRefreshViewportSynchronously?: () => boolean
+ shouldReleaseRenderPause?: () => boolean
onParsed?: () => void
onWriteFailure?: () => void
}
@@ -36,15 +38,21 @@ type ViewportSnapshot = {
function refreshVisibleRows(
terminal: ForegroundTerminalOutputTarget,
- synchronously: boolean
+ synchronously: boolean,
+ shouldReleaseRenderPause?: () => boolean
): void {
if (typeof terminal.rows !== 'number' || terminal.rows < 1) {
return
}
- const start = 0
- const end = Math.max(0, terminal.rows - 1)
try {
+ // Why: only reveal-owned replay may override xterm's paused observer state;
+ // ordinary or newly-hidden output must leave background rendering paused.
+ if (shouldReleaseRenderPause?.() === true && forceRepaintThroughRenderPause(terminal)) {
+ return
+ }
+ const start = 0
+ const end = Math.max(0, terminal.rows - 1)
// Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while
// WebGL can merge this full-grid request into xterm's already-queued frame.
if (synchronously && typeof terminal._core?.refresh === 'function') {
@@ -100,13 +108,14 @@ function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutput
function scheduleViewportSettleRefresh(
terminal: ForegroundTerminalOutputTarget,
- shouldRefreshSynchronously?: () => boolean
+ shouldRefreshSynchronously?: () => boolean,
+ shouldReleaseRenderPause?: () => boolean
): void {
cancelScheduledViewportSettleRefresh(terminal)
if (typeof requestAnimationFrame === 'function') {
const id = requestAnimationFrame(() => {
pendingViewportSettleRefreshByTerminal.delete(terminal)
- refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true)
+ refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause)
})
pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id })
return
@@ -114,7 +123,7 @@ function scheduleViewportSettleRefresh(
const id = setTimeout(() => {
pendingViewportSettleRefreshByTerminal.delete(terminal)
- refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true)
+ refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause)
}, 16)
pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id })
}
@@ -124,7 +133,11 @@ function settleForegroundRender(
beforeWriteViewport: ViewportSnapshot,
options: ForegroundTerminalWriteOptions
): void {
- refreshVisibleRows(terminal, options.shouldRefreshViewportSynchronously?.() ?? true)
+ refreshVisibleRows(
+ terminal,
+ options.shouldRefreshViewportSynchronously?.() ?? true,
+ options.shouldReleaseRenderPause
+ )
// Why: when output advances the viewport, Chromium can paint the freshly
// scrolled top row one frame later than xterm finishes parsing. Repaint once
// more after the scroll settles so the user doesn't need to jiggle the window.
@@ -132,7 +145,11 @@ function settleForegroundRender(
options.followupViewportRefresh ||
viewportChangedDuringWrite(terminal, beforeWriteViewport)
) {
- scheduleViewportSettleRefresh(terminal, options.shouldRefreshViewportSynchronously)
+ scheduleViewportSettleRefresh(
+ terminal,
+ options.shouldRefreshViewportSynchronously,
+ options.shouldReleaseRenderPause
+ )
}
}
diff --git a/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs
new file mode 100644
index 00000000000..78d3596f613
--- /dev/null
+++ b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs
@@ -0,0 +1,139 @@
+// Codex-like INLINE-mode TUI (normal buffer, never alt-screen): history lines
+// scroll into terminal scrollback while a live block (working spinner + input
+// box + status line) repaints glued to the bottom of the screen, wrapped in
+// synchronized-output brackets. This is the write shape a real Codex CLI
+// produces mid-generation — the shape the alt-screen fixtures cannot cover.
+//
+// argv[2] = heartbeat file path (latest frame number, rewritten every tick).
+// argv[3] = history lines per second (default 4) — raise it so a hidden/parked
+// window accumulates a field-sized backlog for the reveal to race.
+// The stream NEVER stops on its own; tests park/hide/reveal around it and
+// assert the revealed terminal converges to the live frame without a resize.
+const fs = require('node:fs')
+
+const heartbeatPath = process.argv[2]
+const TICK_MS = 60
+const HISTORY_LINES_PER_SECOND = Math.max(0, Number(process.argv[3]) || 4)
+const BLOCK_ROWS = 6
+// argv[4]: seed scrollback size — a field Codex session carries thousands of
+// lines, which is what makes the reveal replay long enough to lose races.
+const INITIAL_HISTORY_LINES = Math.max(0, Number(process.argv[4]) || 120)
+
+let frame = 0
+let hist = 0
+
+function rows() {
+ return process.stdout.rows || 24
+}
+
+function cols() {
+ return process.stdout.columns || 80
+}
+
+function historyLine() {
+ hist += 1
+ return `HIST_${String(hist).padStart(6, '0')} tool call output ${'-'.repeat(24)}`
+}
+
+function liveBlock() {
+ const width = Math.max(20, Math.min(cols() - 2, 76))
+ const bar = '─'.repeat(width)
+ const pad = (text) => `${`│ ${text}`.padEnd(width + 1, ' ')}│`
+ const top = Math.max(1, rows() - BLOCK_ROWS + 1)
+ const lines = [
+ `╭${bar}╮`,
+ pad(`CODEX_FRAME_${String(frame).padStart(6, '0')} working${'.'.repeat(frame % 4).padEnd(3)}`),
+ pad(`tokens ${frame * 17} · ${frame % 2 === 0 ? 'thinking' : 'streaming'}`),
+ `╰${bar}╯`,
+ '› INPUT_BOX_READY_MARKER',
+ 'status: streaming · esc to interrupt'
+ ]
+ // Absolute-position to the block top and clear below, like ratatui's inline
+ // viewport redraw.
+ return `\x1b[${top};1H\x1b[J${lines.join('\r\n')}`
+}
+
+// ratatui insert_before-style history: scroll one line into scrollback from
+// the bottom row, then write the new history line just above the live block.
+function insertHistory(count) {
+ const r = rows()
+ const histTop = Math.max(1, r - BLOCK_ROWS)
+ let out = ''
+ for (let i = 0; i < count; i += 1) {
+ out += `\x1b[${r};1H\n\x1b[${histTop};1H${historyLine()}`
+ }
+ return out
+}
+
+let historyCarry = 0
+
+function tick() {
+ frame += 1
+ historyCarry += (HISTORY_LINES_PER_SECOND * TICK_MS) / 1000
+ const historyThisTick = Math.floor(historyCarry)
+ historyCarry -= historyThisTick
+ let out = '\x1b[?2026h\x1b[?25l'
+ if (historyThisTick > 0) {
+ out += insertHistory(historyThisTick)
+ }
+ out += liveBlock()
+ out += '\x1b[?25h\x1b[?2026l'
+ process.stdout.write(out)
+ if (heartbeatPath) {
+ try {
+ fs.writeFileSync(heartbeatPath, String(frame))
+ } catch {
+ // heartbeat is best-effort; the stream itself is the product
+ }
+ }
+}
+
+// Codex-shaped startup: terminal queries (answered by xterm or the daemon's
+// model responder) and mouse reporting, so the run takes the live-agent
+// classification branches instead of the plain-shell ones.
+process.stdout.write('\x1b[c\x1b[6n\x1b]10;?\x07\x1b]11;?\x07')
+process.stdout.write('\x1b[?1002h\x1b[?1006h')
+
+// Seed scrollback before any park so the reveal replays real history.
+{
+ const seed = []
+ for (let i = 0; i < INITIAL_HISTORY_LINES; i += 1) {
+ seed.push(historyLine())
+ }
+ process.stdout.write(`${seed.join('\r\n')}\r\n`)
+}
+
+const tickTimer = setInterval(tick, TICK_MS)
+// A real inline TUI fully repaints its live block on SIGWINCH. Keep that
+// behavior for realism, but tests must converge WITHOUT relying on it.
+process.stdout.on('resize', () => {
+ process.stdout.write(`\x1b[?2026h\x1b[?25l${liveBlock()}\x1b[?25h\x1b[?2026l`)
+})
+// Swallow query replies / mouse reports / keys like a real TUI agent.
+process.stdin.resume()
+if (process.stdin.isTTY) {
+ process.stdin.setRawMode(true)
+}
+
+let stopping = false
+function stop() {
+ if (stopping) {
+ return
+ }
+ stopping = true
+ clearInterval(tickTimer)
+ if (process.stdin.isTTY) {
+ process.stdin.setRawMode(false)
+ }
+ // Why: the e2e sends Ctrl+C while raw mode is active, so Node receives a
+ // byte instead of SIGINT; explicitly restore modes and terminate the fixture.
+ process.stdout.write('\x1b[?1002l\x1b[?1006l\x1b[?25h\x1b[?2026l', () => process.exit(0))
+}
+
+process.stdin.on('data', (data) => {
+ if (Buffer.from(data).includes(3)) {
+ stop()
+ }
+})
+process.on('SIGINT', stop)
+process.on('SIGTERM', stop)
diff --git a/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts
new file mode 100644
index 00000000000..f0ba620eb2c
--- /dev/null
+++ b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts
@@ -0,0 +1,718 @@
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { PNG } from 'pngjs'
+import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import {
+ ensureTerminalVisible,
+ getActiveTabId,
+ switchToOtherWorktree,
+ switchToWorktree,
+ waitForActiveWorktree,
+ waitForSessionReady
+} from './helpers/store'
+import {
+ sendToTerminal,
+ waitForActiveTerminalManager,
+ waitForPaneIdentitySnapshot
+} from './helpers/terminal'
+import { waitForTabParked } from './helpers/terminal-hidden-parking'
+
+// Field bug (v1.4.144-rc.4): switching back to a workspace whose Codex TUI kept
+// streaming while hidden shows a mostly-blank terminal — live block (input box)
+// missing, viewport stranded mid-buffer — until a manual resize (Cmd+L) forces
+// SIGWINCH and Codex repaints. The alt-screen park/reveal specs never caught it
+// because Codex runs in INLINE mode and keeps writing across the reveal.
+//
+// These tests drive a codex-shaped inline TUI that never stops writing, hide
+// the pane across the gate/park boundaries, reveal, and require convergence to
+// the live frame WITHOUT any resize:
+// 1. viewport anchored at the buffer bottom (not stranded mid-scrollback),
+// 2. a recent CODEX_FRAME + the input-box row visible in the on-screen rows,
+// 3. still following (frame number advances on screen) after convergence,
+// 4. xterm grid == fit proposal == PTY-applied size (no stale 80x24 PTY).
+
+const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
+
+test.use({
+ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
+})
+
+const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'codex-inline-live-block-fixture.cjs')
+const FRAME_RE = /CODEX_FRAME_(\d+)/g
+const INPUT_BOX_MARKER = 'INPUT_BOX_READY_MARKER'
+// The fixture ticks every 60ms; allow a generous parse/delivery lag while
+// still rejecting a frozen frame from before the hide.
+const MAX_VISIBLE_FRAME_LAG = 50
+
+type RevealProbe = {
+ ptyId: string | null
+ viewportY: number
+ baseY: number
+ cols: number
+ rows: number
+ proposed: { cols: number; rows: number } | null
+ appliedPtySize: { cols: number; rows: number } | null
+ screenRows: string[]
+}
+
+function latestFrame(text: string): number {
+ let latest = -1
+ for (const match of text.matchAll(FRAME_RE)) {
+ latest = Math.max(latest, Number(match[1]))
+ }
+ return latest
+}
+
+function heartbeatFrame(heartbeatPath: string): number {
+ try {
+ return Number(readFileSync(heartbeatPath, 'utf8').trim())
+ } catch {
+ return -1
+ }
+}
+
+// Why the pane resolves by tab (not a captured ptyId): agent quick-launch
+// startup can respawn the tab's PTY after the initial bind, so a ptyId
+// captured at mount can go stale while the pane itself stays healthy.
+async function probeRevealedPane(page: Page, tabId: string): Promise {
+ return page.evaluate(
+ async ({ tabId }) => {
+ const manager = window.__paneManagers?.get(tabId)
+ const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
+ if (!pane) {
+ return null
+ }
+ const ptyId = pane.container.dataset.ptyId ?? null
+ const terminal = pane.terminal
+ const buffer = terminal.buffer.active
+ const screenRows: string[] = []
+ for (let i = 0; i < terminal.rows; i += 1) {
+ const line = buffer.getLine(buffer.viewportY + i)
+ screenRows.push(line ? line.translateToString(true) : '')
+ }
+ let proposed: { cols: number; rows: number } | null = null
+ try {
+ proposed = pane.fitAddon.proposeDimensions() ?? null
+ } catch {
+ proposed = null
+ }
+ let appliedPtySize: { cols: number; rows: number } | null = null
+ try {
+ appliedPtySize = ptyId ? ((await window.api.pty.getSize(ptyId)) ?? null) : null
+ } catch {
+ appliedPtySize = null
+ }
+ return {
+ ptyId,
+ viewportY: buffer.viewportY,
+ baseY: buffer.baseY,
+ cols: terminal.cols,
+ rows: terminal.rows,
+ proposed,
+ appliedPtySize,
+ screenRows
+ }
+ },
+ { tabId }
+ )
+}
+
+// Painted-pixels check: buffer-level assertions cannot see paint-layer bugs
+// (atlas wipe races, paused-RenderService swallowed refreshes), where xterm's
+// buffer is perfect but the canvas shows blank/stale cells until a resize.
+// Measure the "ink" (non-background pixel ratio) of a horizontal band of the
+// pane screenshot; the fixture's live block guarantees box-drawing + text ink
+// in its bottom rows whenever paint is healthy.
+function measureBandInkRatio(
+ screenshot: Buffer,
+ bandTopFraction: number,
+ bandBottomFraction: number
+): number {
+ const png = PNG.sync.read(screenshot)
+ const colorCounts = new Map()
+ for (let offset = 0; offset < png.data.length; offset += 32) {
+ const key =
+ ((png.data[offset] ?? 0) << 16) |
+ ((png.data[offset + 1] ?? 0) << 8) |
+ (png.data[offset + 2] ?? 0)
+ colorCounts.set(key, (colorCounts.get(key) ?? 0) + 1)
+ }
+ let backgroundKey = 0
+ let backgroundCount = -1
+ for (const [key, count] of colorCounts) {
+ if (count > backgroundCount) {
+ backgroundKey = key
+ backgroundCount = count
+ }
+ }
+ const backgroundRed = (backgroundKey >> 16) & 0xff
+ const backgroundGreen = (backgroundKey >> 8) & 0xff
+ const backgroundBlue = backgroundKey & 0xff
+ const yStart = Math.max(0, Math.floor(png.height * bandTopFraction))
+ const yEnd = Math.min(png.height, Math.ceil(png.height * bandBottomFraction))
+ let ink = 0
+ let total = 0
+ for (let y = yStart; y < yEnd; y += 1) {
+ for (let x = 0; x < png.width; x += 1) {
+ const offset = (y * png.width + x) * 4
+ const diff =
+ Math.abs((png.data[offset] ?? 0) - backgroundRed) +
+ Math.abs((png.data[offset + 1] ?? 0) - backgroundGreen) +
+ Math.abs((png.data[offset + 2] ?? 0) - backgroundBlue)
+ total += 1
+ if (diff > 48) {
+ ink += 1
+ }
+ }
+ }
+ return total > 0 ? ink / total : 0
+}
+
+async function forceWebglOnActiveTab(page: Page): Promise {
+ await page.evaluate(() => {
+ const state = window.__store?.getState()
+ if (!state?.settings) {
+ throw new Error('Store unavailable')
+ }
+ window.__store?.setState({
+ settings: {
+ ...state.settings,
+ terminalGpuAcceleration: 'on'
+ }
+ })
+ const worktreeId = state.activeWorktreeId
+ const tabId =
+ state.activeTabType === 'terminal'
+ ? state.activeTabId
+ : worktreeId
+ ? (state.activeTabIdByWorktree?.[worktreeId] ?? null)
+ : null
+ window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on')
+ })
+}
+
+async function paneClipRect(
+ page: Page,
+ tabId: string
+): Promise<{ x: number; y: number; width: number; height: number } | null> {
+ return page.evaluate((tabId) => {
+ const manager = window.__paneManagers?.get(tabId)
+ const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
+ if (!pane) {
+ return null
+ }
+ const rect = pane.container.getBoundingClientRect()
+ if (rect.width < 10 || rect.height < 10) {
+ return null
+ }
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
+ }, tabId)
+}
+
+async function isTerminalPaneMounted(page: Page, tabId: string): Promise {
+ return page.evaluate((tabId) => {
+ const manager = window.__paneManagers?.get(tabId)
+ return Boolean(manager && manager.getPanes().length > 0)
+ }, tabId)
+}
+
+function describeProbe(probe: RevealProbe | null): string {
+ if (!probe) {
+ return 'pane not mounted'
+ }
+ return JSON.stringify(
+ {
+ viewportY: probe.viewportY,
+ baseY: probe.baseY,
+ cols: probe.cols,
+ rows: probe.rows,
+ proposed: probe.proposed,
+ appliedPtySize: probe.appliedPtySize,
+ screenTail: probe.screenRows.slice(-10)
+ },
+ null,
+ 1
+ )
+}
+
+async function activateTerminalTab(page: Page, tabId: string): Promise {
+ await page.evaluate((targetTabId) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('activateTerminalTab: window.__store is unavailable')
+ }
+ const state = store.getState()
+ state.setActiveTabType('terminal')
+ state.setActiveTab(targetTabId)
+ }, tabId)
+ await expect
+ .poll(() => getActiveTabId(page), {
+ timeout: 5_000,
+ message: `terminal tab ${tabId} did not become active`
+ })
+ .toBe(tabId)
+}
+
+async function createActiveTerminalTab(page: Page, worktreeId: string): Promise {
+ const tabId = await page.evaluate((worktreeId) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('createActiveTerminalTab: window.__store is unavailable')
+ }
+ const state = store.getState()
+ const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
+ state.setActiveTab(tab.id)
+ state.setActiveTabType('terminal')
+ return tab.id
+ }, worktreeId)
+ await expect
+ .poll(() => getActiveTabId(page), {
+ timeout: 5_000,
+ message: 'newly created terminal tab did not become active'
+ })
+ .toBe(tabId)
+ await waitForActiveTerminalManager(page, 30_000)
+ await waitForPaneIdentitySnapshot(page, 1)
+ return tabId
+}
+
+type StreamingTabSetup = {
+ worktreeId: string
+ tabId: string
+ ptyId: string
+ heartbeatPath: string
+ stop: () => Promise
+}
+
+// Why a fresh tab per test: the app instance is shared across this file's
+// serial tests, so reusing the initial tab would type the launch command into
+// the previous test's still-running fixture instead of a shell prompt.
+//
+// Why agent-marked: a real Codex tab carries launchAgent/telemetry, which
+// flips the reveal into the live-agent reattach branches (mode-preserving
+// resets, hidden startup query grammar, post-replay focus-in) — the branches
+// the field bug lives behind.
+async function startStreamingInlineTui(
+ page: Page,
+ testInfo: TestInfo,
+ options: { historyLinesPerSecond?: number; seedLines?: number } = {}
+): Promise {
+ await waitForSessionReady(page)
+ const worktreeId = await waitForActiveWorktree(page)
+ await ensureTerminalVisible(page)
+ const heartbeatPath = testInfo.outputPath(`codex-inline-heartbeat-${Date.now()}.txt`)
+ const command = `node ${JSON.stringify(FIXTURE_PATH)} ${JSON.stringify(heartbeatPath)} ${options.historyLinesPerSecond ?? 4} ${options.seedLines ?? 120}`
+ const tabId = await page.evaluate(
+ ({ worktreeId, command }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('startStreamingInlineTui: window.__store is unavailable')
+ }
+ const state = store.getState()
+ const tab = state.createTab(worktreeId, undefined, undefined, { launchAgent: 'codex' })
+ state.queueTabStartupCommand(tab.id, {
+ command,
+ launchAgent: 'codex',
+ telemetry: {
+ agent_kind: 'codex',
+ launch_source: 'tab_bar_quick_launch',
+ request_kind: 'new'
+ }
+ })
+ state.setActiveTab(tab.id)
+ state.setActiveTabType('terminal')
+ return tab.id
+ },
+ { worktreeId, command }
+ )
+ await expect
+ .poll(() => getActiveTabId(page), {
+ timeout: 5_000,
+ message: 'agent-marked streaming tab did not become active'
+ })
+ .toBe(tabId)
+ await waitForActiveTerminalManager(page, 30_000)
+ await waitForPaneIdentitySnapshot(page, 1)
+ // WebGL on: the paint-layer seams under test (atlas wipes, render-pause
+ // release) only exist on the GPU renderer path.
+ await forceWebglOnActiveTab(page)
+ await expect
+ .poll(
+ async () => latestFrame((await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? ''),
+ {
+ timeout: 20_000,
+ message: 'inline TUI fixture did not start streaming in the visible pane'
+ }
+ )
+ .toBeGreaterThan(5)
+ // Capture the PTY id only after the fixture streams: agent quick-launch can
+ // respawn the tab's PTY when the startup command binds.
+ const ptyId = (await probeRevealedPane(page, tabId))?.ptyId
+ if (!ptyId) {
+ throw new Error('streaming tab did not bind a PTY')
+ }
+ return {
+ worktreeId,
+ tabId,
+ ptyId,
+ heartbeatPath,
+ stop: async () => {
+ // Ctrl+C so the shared app does not accumulate streaming fixtures.
+ await sendToTerminal(page, ptyId, '\x03').catch(() => {})
+ await page.waitForTimeout(100)
+ }
+ }
+}
+
+async function assertRevealConvergence(
+ page: Page,
+ testInfo: TestInfo,
+ setup: StreamingTabSetup,
+ label: string
+): Promise {
+ const { tabId, heartbeatPath } = setup
+
+ // Premise: the fixture kept streaming while hidden.
+ const heartbeatAtReveal = heartbeatFrame(heartbeatPath)
+ expect(heartbeatAtReveal, 'fixture stopped streaming while hidden').toBeGreaterThan(5)
+
+ let lastProbe: RevealProbe | null = null
+ await expect
+ .poll(
+ async () => {
+ lastProbe = await probeRevealedPane(page, tabId)
+ if (!lastProbe) {
+ return 'pane-not-mounted'
+ }
+ if (lastProbe.viewportY !== lastProbe.baseY) {
+ return `viewport-stranded viewportY=${lastProbe.viewportY} baseY=${lastProbe.baseY}`
+ }
+ const screen = lastProbe.screenRows.join('\n')
+ if (!screen.includes(INPUT_BOX_MARKER)) {
+ return 'input-box-row-missing'
+ }
+ const visibleFrame = latestFrame(screen)
+ const liveFrame = heartbeatFrame(heartbeatPath)
+ if (visibleFrame < 0 || liveFrame - visibleFrame > MAX_VISIBLE_FRAME_LAG) {
+ return `stale-frame visible=${visibleFrame} live=${liveFrame}`
+ }
+ return 'converged'
+ },
+ {
+ timeout: 20_000,
+ message: `${label}: revealed pane did not converge to the live inline TUI without a resize`
+ }
+ )
+ .toBe('converged')
+ .catch(async (error) => {
+ testInfo.annotations.push({
+ type: `${label}-divergence-probe`,
+ description: describeProbe(lastProbe)
+ })
+ const screenshotPath = testInfo.outputPath(`${label}-divergence.png`)
+ await page.screenshot({ path: screenshotPath, fullPage: true })
+ await testInfo.attach(`${label}-divergence.png`, {
+ path: screenshotPath,
+ contentType: 'image/png'
+ })
+ throw error
+ })
+
+ // Still following: the on-screen frame must keep advancing after convergence
+ // with the viewport still pinned to the bottom.
+ const convergedFrame = latestFrame(
+ (await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? ''
+ )
+ await expect
+ .poll(
+ async () => {
+ const probe = await probeRevealedPane(page, tabId)
+ if (!probe || probe.viewportY !== probe.baseY) {
+ return -1
+ }
+ return latestFrame(probe.screenRows.join('\n'))
+ },
+ {
+ timeout: 10_000,
+ message: `${label}: revealed pane stopped following live output after convergence`
+ }
+ )
+ .toBeGreaterThan(convergedFrame)
+
+ // Geometry: no stale-80x24 leg — xterm grid, fit proposal, and PTY-applied
+ // size must agree without any manual resize.
+ const probe = await probeRevealedPane(page, tabId)
+ expect(probe, `${label}: pane disappeared after convergence`).not.toBeNull()
+ expect(probe!.proposed, `${label}: fit proposal diverges: ${describeProbe(probe)}`).toEqual({
+ cols: probe!.cols,
+ rows: probe!.rows
+ })
+ expect(
+ probe!.appliedPtySize,
+ `${label}: PTY applied-size read unavailable: ${describeProbe(probe)}`
+ ).not.toBeNull()
+ expect(
+ probe!.appliedPtySize,
+ `${label}: PTY applied size diverges: ${describeProbe(probe)}`
+ ).toEqual({ cols: probe!.cols, rows: probe!.rows })
+
+ // Painted pixels: the live block guarantees box-drawing + text ink in the
+ // pane's bottom rows. Blank band + healthy buffer = paint-layer divergence
+ // (atlas wipe race / paused RenderService) — the class a resize also heals.
+ const clip = await paneClipRect(page, tabId)
+ expect(clip, `${label}: pane rect unavailable for paint check`).not.toBeNull()
+ const bandTop = Math.max(0, 1 - 8 / (probe!.rows || 24))
+ await expect
+ .poll(
+ async () => {
+ const shot = await page.screenshot({ clip: clip! })
+ return measureBandInkRatio(shot, bandTop, 1)
+ },
+ {
+ timeout: 10_000,
+ message: `${label}: live block rows painted blank while the buffer holds content (paint-layer divergence)`
+ }
+ )
+ .toBeGreaterThan(0.005)
+}
+
+async function resizeAppWindow(
+ electronApp: ElectronApplication,
+ deltaWidth: number,
+ deltaHeight: number
+): Promise {
+ // Why the retry: the main-process utility context Playwright evaluates in
+ // can be transiently recycled ("Execution context was destroyed") right
+ // after heavy renderer work like a worktree switch; the resize itself is
+ // idempotent-safe to attempt again.
+ let lastError: unknown = null
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await electronApp.evaluate(
+ ({ BrowserWindow }, { deltaWidth, deltaHeight }) => {
+ const window = BrowserWindow.getAllWindows()[0]
+ if (!window) {
+ throw new Error('No Electron window')
+ }
+ const [width, height] = window.getSize()
+ window.setSize(width + deltaWidth, height + deltaHeight)
+ },
+ { deltaWidth, deltaHeight }
+ )
+ return
+ } catch (error) {
+ lastError = error
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+ }
+ throw lastError
+}
+
+// CPU throttling around the reveal action simulates the loaded-machine
+// conditions the field failures occur under, deterministically.
+async function withCpuThrottle(page: Page, rate: number, run: () => Promise): Promise {
+ const session = await page.context().newCDPSession(page)
+ try {
+ await session.send('Emulation.setCPUThrottlingRate', { rate })
+ return await run()
+ } finally {
+ await session.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => {})
+ await session.detach().catch(() => {})
+ }
+}
+
+async function streamWhileParked(setup: StreamingTabSetup, minFrames: number): Promise {
+ const heartbeatBefore = heartbeatFrame(setup.heartbeatPath)
+ await expect
+ .poll(() => heartbeatFrame(setup.heartbeatPath), {
+ timeout: 60_000,
+ message: 'fixture did not keep streaming while hidden/parked'
+ })
+ .toBeGreaterThan(heartbeatBefore + minFrames)
+}
+
+test.describe('Inline TUI reveal convergence', () => {
+ test('hidden-but-mounted tab reveal converges while the inline TUI streams', async ({
+ orcaPage
+ }, testInfo) => {
+ test.setTimeout(120_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo)
+ try {
+ // Tab B hides tab A. Reveal quickly — inside the cold-park delay — so
+ // the reveal exercises the hidden-delivery-gate restore, not parking.
+ const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
+ expect(tabBId).not.toBe(setup.tabId)
+ await orcaPage.waitForTimeout(Math.max(50, Math.min(PARKING_DELAY_MS / 2, 200)))
+ expect(
+ await isTerminalPaneMounted(orcaPage, setup.tabId),
+ 'hidden-mounted scenario cold-parked before reveal'
+ ).toBe(true)
+
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await assertRevealConvergence(orcaPage, testInfo, setup, 'hidden-mounted-reveal')
+ } finally {
+ await setup.stop()
+ }
+ })
+
+ test('worktree switch reveal converges after a hidden-time window resize', async ({
+ orcaPage,
+ electronApp
+ }, testInfo) => {
+ test.setTimeout(120_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo, {
+ historyLinesPerSecond: 20
+ })
+ try {
+ // Surface hide: switch to ANOTHER WORKTREE (the field action), which
+ // suspends rendering and takes the heavy resume path on return.
+ const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId)
+ test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide')
+
+ // Change the window size while the pane is display:none (0x0 container,
+ // no fit runs). This is what Cmd+L's sidebar toggle does to every hidden
+ // workspace: at reveal the pane grid differs from the daemon snapshot's.
+ await resizeAppWindow(electronApp, -180, -120)
+ await orcaPage.waitForTimeout(2_500)
+
+ await switchToWorktree(orcaPage, setup.worktreeId)
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-resize-reveal')
+ } finally {
+ await resizeAppWindow(electronApp, 180, 120).catch(() => {})
+ await setup.stop()
+ }
+ })
+
+ test('parked tab reveal converges across repeated cycles while the inline TUI streams heavily', async ({
+ orcaPage
+ }, testInfo) => {
+ test.setTimeout(480_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo, {
+ historyLinesPerSecond: 30,
+ seedLines: 8_000
+ })
+ try {
+ // Tab B hides tab A; the decoy then hides tab B so B (most recently
+ // hidden) takes the #8262 last-active exemption and tab A cold-parks.
+ const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
+ const decoyTabId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
+
+ // The field failure is periodic, not every reveal — cycle the park →
+ // stream → reveal boundary and require convergence every time.
+ const CYCLES = 6
+ for (let cycle = 0; cycle < CYCLES; cycle += 1) {
+ if (cycle > 0) {
+ await activateTerminalTab(orcaPage, tabBId)
+ await activateTerminalTab(orcaPage, decoyTabId)
+ }
+ await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS })
+
+ // Accumulate a field-sized backlog against the parked (unmounted)
+ // view so the reveal replay races the live stream, like a real Codex.
+ await streamWhileParked(setup, 100)
+
+ // Reveal under CPU throttle: a long replay parse + throttled frames is
+ // the loaded-machine window where the corrective fit and follow-anchor
+ // lose their races in the field.
+ await withCpuThrottle(orcaPage, 6, async () => {
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await orcaPage.waitForTimeout(3_000)
+ })
+ const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId)
+ await assertRevealConvergence(orcaPage, testInfo, setup, `parked-heavy-reveal-c${cycle}`)
+ }
+ } finally {
+ await setup.stop()
+ }
+ })
+
+ test('rapid tab hide/reveal flapping never wedges delivery for the streaming inline TUI', async ({
+ orcaPage
+ }, testInfo) => {
+ test.setTimeout(150_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo, {
+ historyLinesPerSecond: 10
+ })
+ try {
+ const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
+ // Rapid flapping drives the hidden-delivery gate claim/release IPC and
+ // the hidden-output restore against each other at varied phases — the
+ // desync class behind "bytes dropped on a visible pane" field freezes.
+ for (let flap = 0; flap < 12; flap += 1) {
+ await activateTerminalTab(orcaPage, tabBId)
+ await orcaPage.waitForTimeout(50 + (flap % 3) * 120)
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await orcaPage.waitForTimeout(50 + ((flap * 7) % 5) * 90)
+ }
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await assertRevealConvergence(orcaPage, testInfo, setup, 'tab-flapping-reveal')
+ } finally {
+ await setup.stop()
+ }
+ })
+
+ test('rapid worktree switch flapping never wedges delivery for the streaming inline TUI', async ({
+ orcaPage
+ }, testInfo) => {
+ test.setTimeout(150_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo, {
+ historyLinesPerSecond: 10,
+ seedLines: 4_000
+ })
+ try {
+ const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId)
+ test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-flap')
+ // Surface-level flapping (the field action): suspend/resume rendering +
+ // heavy resume path race the gate resync and reveal repaint each cycle,
+ // under CPU throttle to widen the race windows like a loaded machine.
+ await withCpuThrottle(orcaPage, 6, async () => {
+ for (let flap = 0; flap < 10; flap += 1) {
+ await switchToWorktree(orcaPage, otherWorktreeId!)
+ await orcaPage.waitForTimeout(60 + (flap % 4) * 110)
+ await switchToWorktree(orcaPage, setup.worktreeId)
+ await orcaPage.waitForTimeout(60 + ((flap * 5) % 4) * 130)
+ }
+ })
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-flapping-reveal')
+ } finally {
+ await setup.stop()
+ }
+ })
+
+ test('parked tab reveal converges after a parked-time window resize', async ({
+ orcaPage,
+ electronApp
+ }, testInfo) => {
+ test.setTimeout(180_000)
+ const setup = await startStreamingInlineTui(orcaPage, testInfo, {
+ historyLinesPerSecond: 20
+ })
+ try {
+ await createActiveTerminalTab(orcaPage, setup.worktreeId)
+ await createActiveTerminalTab(orcaPage, setup.worktreeId)
+ await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS })
+
+ // Resize while parked: the remount measures a grid that matches neither
+ // the pre-park xterm nor the daemon snapshot — maximum dimension churn.
+ await resizeAppWindow(electronApp, -180, -120)
+ await streamWhileParked(setup, 100)
+
+ await activateTerminalTab(orcaPage, setup.tabId)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
+ expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId)
+ await assertRevealConvergence(orcaPage, testInfo, setup, 'parked-resize-reveal')
+ } finally {
+ await resizeAppWindow(electronApp, 180, 120).catch(() => {})
+ await setup.stop()
+ }
+ })
+})
From c0f0810dd9c89c57e2130e5d7b5adcbef883845c Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 19 Jul 2026 20:25:52 -0700
Subject: [PATCH 03/35] Fix native Windows PTY startup query handling (#9500)
* Fix native Windows PTY startup query handling
* Fix daemon boot smoke protocol lookup
* Fix Windows daemon repro protocol lookup
---
config/scripts/daemon-boot-smoke.mjs | 5 +-
.../windows-daemon-workspace-close-repro.mjs | 5 +-
...onpty-startup-query-and-focus-authority.md | 523 ++++++++++++++++++
src/main/daemon/daemon-protocol-version.ts | 12 +
src/main/daemon/daemon-pty-adapter.test.ts | 66 +++
src/main/daemon/daemon-pty-adapter.ts | 34 +-
src/main/daemon/daemon-pty-router.ts | 14 +-
src/main/daemon/daemon-server.ts | 16 +-
.../daemon/daemon-stream-data-batcher.test.ts | 17 +
src/main/daemon/daemon-stream-data-batcher.ts | 49 +-
src/main/daemon/daemon-stream-data-entry.ts | 46 ++
src/main/daemon/daemon-stream-data-split.ts | 42 +-
src/main/daemon/daemon-stream-events.ts | 9 +-
.../daemon/daemon-stream-keep-tail-drop.ts | 2 +
.../daemon/degraded-daemon-pty-provider.ts | 15 +-
src/main/daemon/session.test.ts | 56 ++
src/main/daemon/session.ts | 56 +-
.../daemon/terminal-host-create-contract.ts | 7 +-
src/main/daemon/terminal-host-options.ts | 27 +
src/main/daemon/terminal-host.ts | 37 +-
src/main/daemon/terminal-modes.ts | 11 +
src/main/daemon/terminal-snapshot.ts | 20 +
src/main/daemon/types.ts | 76 +--
src/main/ipc/pty.test.ts | 107 +++-
src/main/ipc/pty.ts | 169 +++---
src/main/ipc/ssh.test.ts | 17 +-
.../terminal-startup-color-query-replies.ts | 141 +----
src/main/providers/local-pty-provider.test.ts | 41 ++
src/main/providers/local-pty-provider.ts | 65 ++-
src/main/providers/pty-provider-events.ts | 22 +
src/main/providers/ssh-pty-provider.test.ts | 57 ++
src/main/providers/ssh-pty-provider.ts | 34 +-
src/main/providers/types.ts | 30 +-
src/main/runtime/orca-runtime.ts | 23 +-
src/main/runtime/rpc/methods/terminal.ts | 49 +-
.../ssh-relay-session-data-delivery.test.ts | 112 ++++
.../ssh-relay-session-terminal-error.test.ts | 3 +-
src/main/ssh/ssh-relay-session.test.ts | 10 +-
src/main/ssh/ssh-relay-session.ts | 27 +-
src/preload/api-types.ts | 1 +
src/preload/index.ts | 2 +
src/relay/pty-handler.test.ts | 67 +++
src/relay/pty-handler.ts | 134 ++++-
.../terminal-pane/pty-connection.ts | 1 +
.../terminal-pane/pty-dispatcher.ts | 6 +
...runtime-terminal-frame-drop-resync.test.ts | 47 +-
.../remote-runtime-terminal-multiplexer.ts | 46 +-
src/shared/pty-startup-ingress-intent.ts | 52 ++
src/shared/pty-startup-ingress.test.ts | 158 ++++++
src/shared/pty-startup-ingress.ts | 340 ++++++++++++
src/shared/terminal-stream-protocol.ts | 6 +-
51 files changed, 2415 insertions(+), 497 deletions(-)
create mode 100644 docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md
create mode 100644 src/main/daemon/daemon-protocol-version.ts
create mode 100644 src/main/daemon/daemon-stream-data-entry.ts
create mode 100644 src/main/daemon/terminal-host-options.ts
create mode 100644 src/main/daemon/terminal-modes.ts
create mode 100644 src/main/daemon/terminal-snapshot.ts
create mode 100644 src/main/providers/pty-provider-events.ts
create mode 100644 src/main/ssh/ssh-relay-session-data-delivery.test.ts
create mode 100644 src/shared/pty-startup-ingress-intent.ts
create mode 100644 src/shared/pty-startup-ingress.test.ts
create mode 100644 src/shared/pty-startup-ingress.ts
diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs
index dc593b650b2..4ed155e8c5f 100644
--- a/config/scripts/daemon-boot-smoke.mjs
+++ b/config/scripts/daemon-boot-smoke.mjs
@@ -36,10 +36,11 @@ function log(message) {
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
- const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
+ const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
+ const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
- throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
+ throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
}
return Number(match[1])
}
diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs
index 974e865722d..1385ffc17d2 100644
--- a/config/scripts/windows-daemon-workspace-close-repro.mjs
+++ b/config/scripts/windows-daemon-workspace-close-repro.mjs
@@ -22,7 +22,10 @@ function log(message) {
}
function readProtocolVersion() {
- const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
+ const source = readFileSync(
+ join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'),
+ 'utf8'
+ )
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('Could not read the daemon protocol version')
diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md
new file mode 100644
index 00000000000..1a0c9922a35
--- /dev/null
+++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md
@@ -0,0 +1,523 @@
+# Windows ConPTY Startup Query and Focus Authority Design
+
+Date: 2026-07-19
+
+Status: Final decision; implementation pending
+
+## Problem
+
+On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user
+text:
+
+```text
+]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\
+```
+
+An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal
+focus-in report `CSI I`.
+
+The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The
+agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only
+about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in
+addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent
+to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order
+records that cooked echo in the authoritative runtime model before any renderer-bound filtering.
+
+The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits
+DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native
+PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was
+consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol;
+the failure requires an additional agent, timing, input-mode, or replay condition.
+
+## Root Cause
+
+The startup OSC responder currently runs in Electron main only after runtime ingestion
+([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
+data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
+daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
+data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
+renderer-only filter can therefore hide the symptom without removing it from the authoritative
+model, daemon history, snapshots, or remote delivery.
+
+The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
+downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
+that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness
+without pre-approving a speculative focus workaround.
+
+## Data Flow
+
+```text
+node-pty / remote relay PTY
+ -> shell-ready marker scan
+ -> source-owned serialized ingress transaction
+ -> consume an early OSC query and write one canonical reply
+ -> match or losslessly release the measured native-Windows echo projection
+ -> authoritative emulator + persistence/history
+ -> runtime side effects and mobile/remote stream
+ -> renderer delivery or hidden-drop decision
+ -> live terminal / snapshot restore
+```
+
+Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed
+spans advance sequence and flow-control state without writing bytes into an emulator or view.
+
+## Rejected Prototype
+
+The current working-tree prototype is not the implementation of this design:
+
+- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus
+ semantics for every Windows PTY, including programs unrelated to agents.
+- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a
+ hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage.
+- Its echo state activates only after both color slots are answered. An echo of the first response
+ can escape if it arrives before the second query.
+- Its partial-match buffer can be lost on timeout, and its substring search can remove a later
+ legitimate string that happens to equal a reply.
+
+Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked
+echo filtering. Existing unrelated Windows focus-idle safeguards remain.
+
+## Existing Contracts Preserved
+
+This design extends, rather than replaces:
+
+- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query
+ authority, authoritative model restore, and raw sequence ordering;
+- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus-
+ hidden-dropped ownership and replay silence;
+- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY
+ bytes once in main before renderer delivery.
+
+The startup responder is a bounded exception needed before normal delivered/dropped ownership can be
+established. It must join the same ingestion decision, not operate as an unrelated renderer scrub.
+
+## Decision 1: Source-Owned PTY Ingress Transaction
+
+Sanitization must run on the host that owns the PTY, before that host mutates any authoritative
+model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced
+its sequence, written its emulator, recorded pending output, and broadcast the data before main's
+provider callback runs.
+
+The transaction therefore has three installations of the same shared state machine:
+
+- in `LocalPtyProvider`, before its configured runtime callback and data listeners;
+- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and
+ attached-client fanout;
+- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's
+ `SshPtyProvider` is only an RPC proxy and never sanitizes relay output.
+
+Electron main receives already-classified daemon data. A remote-runtime desktop client does not
+reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual
+provider owner but never enable the native-Windows compatibility projection.
+
+Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach`
+receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed
+color attributes. It first decides fresh versus reattach; only a fresh result constructs the
+transaction before releasing the subprocess's already-buffered early output. A reattach discards
+the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any
+PTY id can be reused.
+
+Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn`
+carries the same fresh-session intent and installs it before releasing relay PTY output; relay
+`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is
+not armed and normal query authority handles the query.
+
+Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold
+predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported
+legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a
+separate versioned spawn/data capability because daemon protocol support says nothing about relay
+support.
+
+An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots
+or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive
+the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached
+legacy session remains explicitly outside it.
+
+### Composition with shell-ready preprocessing
+
+The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold
+transport bytes and removes its private ready marker; marker bytes have never belonged to terminal
+model or delivery sequence space and continue not to count. Its released non-marker bytes enter the
+new ingress transaction in original order.
+
+Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its
+non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model,
+persistence, and views observe the resulting emissions. No later stage may introduce an unmetered
+string transform.
+
+### Raw sequence and emission contract
+
+The state machine accepts source chunks with an explicit raw half-open range and returns zero or
+more ordered emissions:
+
+```ts
+type PtyIngressSourceChunk = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+}
+
+type PtyIngressEmission = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+ transformed: boolean
+}
+```
+
+The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when
+`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without
+overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once
+from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the
+buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it.
+
+The ingress raw high-water advances when a source chunk is accepted. Model-applied and
+view-delivered high-waters advance only through ordered emissions, including empty transformed
+emissions that consume a raw range. Runtime/mobile listener metadata carries
+`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span
+cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching
+the existing `rawLength !== data.length` safety rule.
+
+This metadata is end-to-end, not local to the state machine. Implementation changes the complete
+path:
+
+1. daemon `Session` or relay `PtyHandler` emission callback;
+2. daemon/relay batching, coalescing, splitting, and wire notification;
+3. daemon adapter or `SshPtyProvider` decode;
+4. `OrcaRuntimeService` model and runtime/mobile listeners;
+5. main renderer batching, pending/drop accounting, and preload payload;
+6. renderer reconciliation and remote-runtime binary/live frame decoding.
+
+The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A
+span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing
+its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted
+only for contiguous spans and sums raw lengths independently of string lengths. A transformed
+emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush
+it as its own frame or request snapshot reconciliation instead of slicing it.
+
+If a snapshot is requested while a partial candidate is held, the transaction first abandons that
+candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and
+snapshot sequence therefore describe the same raw high-water.
+
+### Serialization and teardown
+
+Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a
+nested callback, but that callback is appended after the current source chunk rather than delivered
+ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue.
+
+On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model
+and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay
+disposal must flush both ingress prefixes and its existing pending output batches before clearing
+them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate
+state after teardown. Ordinary chunks pass through as one unchanged emission.
+
+## Decision 2: Startup OSC Queries Are Consumed Authoritatively
+
+The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup
+and the agent's short color-query timeout. Its implementation moves into the source-owned ingress
+transaction.
+
+When registered for an agent spawn, it:
+
+1. recognizes exact OSC 10/11 query grammar across provider chunks;
+2. builds replies from validated renderer-pushed foreground/background attributes;
+3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority,
+ regardless of whether the query ended with BEL or ST;
+4. records each reply transaction before writing it to the provider;
+5. consumes the answered query from the authoritative model and view emissions so neither the
+ hidden model nor a delivered renderer can answer it a second time;
+6. begins echo recognition as soon as each individual reply is written.
+
+If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
+to normal query authority. A reattach never registers startup response state, matching current
+behavior.
+
+### Exact authority transfer
+
+Startup query interception opens only for a fresh session whose atomic creation installed the
+transaction before buffered output release. It closes at the first of:
+
+- both OSC 10 and OSC 11 slots have been answered;
+- the startup deadline expires;
+- main sends an ordered authority-close control after either the consuming-view handshake or the
+ hidden-runtime ownership mark is established;
+- the spawn fails, is cancelled, reattaches, or exits.
+
+Closing query interception does not discard already-written reply candidates. Echo recognition has
+its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
+provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
+a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
+method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered
+boundary are either consumed at source or removed from emissions; queries after it pass unchanged
+to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority.
+
+The regular authority rules continue after the bounded startup window:
+
+- delivered live bytes are answered by the live view;
+- hidden-dropped live bytes are answered by the runtime model;
+- replayed, seeded, and snapshot bytes are answered by nobody;
+- the daemon's persistence emulator never writes replies.
+
+Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to
+name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a
+real third responder class, not an undocumented exception.
+
+## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model
+
+Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a
+compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do
+not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and
+capability gate.
+
+The harness records the exact projection ConPTY returns for the canonical ST reply, including its
+chunking and any console transformation. The implementation must not assume that the projection is
+always merely "remove ESC", and BEL or other reply forms are not added without separate evidence.
+For every written reply, the transaction records that exact expected projection. Recognition is:
+
+- FIFO in reply-write order;
+- anchored at the next possible output position, not an unbounded substring search;
+- active immediately for each reply instead of waiting for both color slots;
+- bounded by the startup deadline and maximum reply length;
+- streaming across chunk boundaries.
+
+If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged
+and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is
+buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A
+match advances the raw span but emits no bytes to the model, persistence, or views.
+
+Projection matching cannot prove provenance. An application can print the same projected text at
+the candidate position, causing a false-positive removal while the later real echo remains visible.
+This is the central drawback of the workaround. It is accepted only if the real provider harness
+shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise
+Orca disables the projection and keeps the visible output rather than risking deletion.
+
+When the projection is enabled, the exact-collision behavior is explicit: the first identical
+anchored candidate is removed and a later real echo is allowed through. The test fixture must assert
+that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance
+is knowable; the release gate must document the observed timing window and justify that tradeoff.
+Unknown, delayed, or interleaved transformations pass through visibly.
+
+## Decision 4: Preserve ConPTY Focus Protocol
+
+Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It
+must not remove, reorder, or fabricate transport bootstrap modes.
+
+The `[I` investigation gets a deterministic harness before a behavior change. The harness must
+exercise the actual renderer focus callback and provider write path, not only inject `CSI I`
+directly into node-pty. It records:
+
+- raw provider output and its order;
+- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004,
+ or replayed snapshot state;
+- the exact bytes written to the provider;
+- the exact bytes returned by the provider and stored in the model;
+- agent lifecycle state when the report was emitted.
+
+The implementation gate is strict:
+
+- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode
+ is not persisted as application ownership.
+- If a live agent receives transport focus before it owns terminal focus reporting, record that
+ evidence without shipping a suppression heuristic from this document.
+- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize
+ that transformation at the same pre-model ingress boundary used for OSC replies.
+
+No global `?1004h` filter ships under any outcome.
+
+If the harness proves a focus-ownership race, a follow-up design is required before implementation.
+That design must define the output-to-input ownership signal, distinguish xterm focus events from
+identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any
+snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document
+does not pre-approve an ownership state machine whose input provenance cannot yet be represented.
+
+## Snapshot and Replay Rules
+
+Interactive modes in a snapshot are capabilities of the live application, not proof that a new
+view should emit input immediately.
+
+- Cold restore into a fresh shell keeps the existing full mode reset.
+- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only
+ bootstrap ownership.
+- Snapshot serialization/replay must not mutate live ownership trackers.
+- A snapshot containing an OSC query or an old Orca reply never produces a provider write.
+- Model and renderer snapshots must both be free of matched cooked reply projections.
+
+Any future focus ownership metadata must be explicit; it cannot be inferred from serialized
+`?1004h` text.
+
+## Failure Policy
+
+Safety is asymmetric:
+
+- This design does not intentionally suppress a focus notification; evidence of a focus-ownership
+ race triggers a follow-up design instead.
+- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the
+ application.
+- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate
+ replies are forbidden.
+- Losing buffered output on timeout or teardown is forbidden.
+
+## Cross-Platform and Remote Scope
+
+- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility
+ path; focus investigation only until a proven root cause has its own complete design.
+- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround.
+- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the
+ remote host protocol later supplies explicit equivalent evidence.
+- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there;
+ desktop local main does not reinterpret its stream.
+- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query
+ response authority through the existing terminal-driver election.
+
+## Edge Cases
+
+- A query or echo split at any byte boundary, including OSC ST split across chunks.
+- The first reply echo arriving before the second color query.
+- Unrelated output, an exact application-text collision, or a partial match before the real echo.
+- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held.
+- A provider write causing a synchronous nested output callback.
+- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing
+ with output.
+- BEL-terminated queries still receiving the canonical ST reply.
+- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers.
+- Old daemon/relay protocol versions and sessions that survive an application upgrade.
+- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds.
+- Focus gained through normal terminal input versus the separate explicit reattach-focus write path.
+
+## Test Plan
+
+### Deterministic provider harness
+
+- Capture the native ConPTY bootstrap across natural and forced chunk boundaries.
+- Reproduce the OSC reply echo with the real provider and an agent query fixture.
+- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11
+ queries and combined OSC 10 `?;?`.
+- Force the first reply echo before the second query.
+- Split every byte boundary in query and echo fixtures.
+- Print the expected projection immediately before the real echo and assert that the first
+ identical candidate is removed while the later echo passes through.
+- Exercise focus gain/loss through the actual xterm focus path.
+
+### Ingress integration
+
+- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive
+ classified data before storing it.
+- Assert renderer and mobile delivery receive the same visible output.
+- Assert raw start/end spans advance by original provider length, producer ACK contribution is
+ counted exactly once, and mobile/runtime metadata reports the raw span rather than string length.
+- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move,
+ and teardown, including a prefix held from an earlier callback.
+- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the
+ write.
+- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather
+ than slicing cleaned text.
+- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam;
+ assert `SshPtyProvider` does not reinterpret relay data.
+- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection.
+- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold
+ restore; assert capable relay replay/history has the same property. Assert old daemon and relay
+ versions are detected and never represented as sanitized.
+
+### Authority and restore
+
+- Assert an early-consumed query is answered once by its source owner and never by renderer or
+ hidden model.
+- Assert normal delivered/dropped query authority resumes after startup state clears.
+- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do
+ not contain cooked OSC text.
+- Assert replay never sends OSC or focus replies.
+- Assert ordinary native console focus behavior remains enabled.
+- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any
+ later ownership implementation defines its tests in the required follow-up design.
+
+### End-to-end acceptance
+
+On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions.
+Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the
+focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops
+at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus
+behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain
+PowerShell terminal and a native focus-event consumer to prove no global regression.
+
+Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint,
+formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also
+require the repository's SSH end-to-end procedure.
+
+## UI Quality Bar
+
+This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must
+look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a
+clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and
+focus presentation.
+
+## Review Screenshots
+
+1. A fresh native-Windows agent prompt after startup, with no OSC reply text.
+2. The same session after hide/reveal restore, still clean and without duplicated output.
+3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state.
+4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior.
+
+## Rollout
+
+1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress
+ state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready
+ preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay
+ `PtyHandler` before their models, persistence/replay, and fanout.
+2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates,
+ carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query
+ authority documents with exact transfer events.
+3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical
+ ST replies, consume answered queries for model and views, and retain raw sequence accounting.
+4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO
+ anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage,
+ and authoritative daemon/model/snapshot tests.
+5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop
+ for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed
+ only with a failing regression test that selects that branch.
+6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL,
+ and SSH scenarios before removing the old startup implementation.
+
+Each slice must keep query authority singular. The compatibility projection does not ship without a
+model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness
+fails on the old behavior and passes on the new behavior.
+
+## Lightweight Eng Review
+
+- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global
+ focus filtering and an unproven ownership state machine remain out of scope.
+- Architecture/data flow: classification belongs at each PTY source owner after shell-ready
+ preprocessing and before every authoritative model, persistence, replay, or delivery consumer.
+- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes,
+ authority races, snapshot and teardown drains, protocol-version skew, reattach, and host
+ isolation.
+- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay,
+ runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and
+ renderer-focus harnesses; Electron and SSH end-to-end validation.
+- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one
+ startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery
+ path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory.
+- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage
+ disappears, without cursor/focus regressions or restore flashes.
+- Required review screenshots: the four terminal states in `Review Screenshots`.
+- Residual risks: the native projection may be too unstable to enable; exact projected application
+ output can collide; `[I` may require a separately reviewed focus-ownership design.
+
+## Non-Goals
+
+- Replacing xterm's general query parser.
+- Filtering arbitrary escape-looking terminal output.
+- Disabling ConPTY focus or Win32 input mode globally.
+- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows.
+- Solving unrelated MCP startup warnings reported beside the terminal garbage.
+
+## Final Invariants
+
+1. Provider bytes are classified once before model ingestion and view delivery.
+2. A live query has exactly one responder; replay has none.
+3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched
+ startup-reply projection.
+4. Raw provider sequence accounting survives sanitization.
+5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data.
+6. ConPTY bootstrap modes reach live emulators unchanged.
+7. No focus suppression ships from this design; a proven ownership race requires a follow-up design.
+8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state.
diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts
new file mode 100644
index 00000000000..119a3bb14d7
--- /dev/null
+++ b/src/main/daemon/daemon-protocol-version.ts
@@ -0,0 +1,12 @@
+// Why: daemons survive app updates, so wire behavior must be version-gated.
+export const PROTOCOL_VERSION = 24
+export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
+export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
+export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
+export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
+] as const
+
+export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+ return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
+}
diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts
index 14243aa15eb..707d2800e15 100644
--- a/src/main/daemon/daemon-pty-adapter.test.ts
+++ b/src/main/daemon/daemon-pty-adapter.test.ts
@@ -144,6 +144,72 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
+ it('carries classified startup spans from the daemon source to the adapter', async () => {
+ const onData = vi.fn()
+ adapter.onData(onData)
+ const { id } = await adapter.spawn({
+ cols: 80,
+ rows: 24,
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000,
+ ...(process.platform === 'win32'
+ ? { echoProjection: 'windows-conpty-esc-stripped' as const }
+ : {})
+ }
+ })
+ const query = '\x1b]10;?\x07'
+ lastSubprocess._simulateData(query)
+ lastSubprocess._simulateData('prompt')
+
+ await waitFor(() => onData.mock.calls.length >= 2)
+
+ expect(lastSubprocess.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
+ expect(onData).toHaveBeenCalledWith({
+ id,
+ data: '',
+ sequenceChars: query.length,
+ seq: query.length,
+ transformed: true
+ })
+ expect(onData).toHaveBeenCalledWith({ id, data: 'prompt' })
+ await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({
+ data: expect.not.stringContaining(']10;rgb')
+ })
+ })
+
+ it('omits startup intent and close control for the preserved v23 protocol', async () => {
+ const ensureConnectedSpy = vi
+ .spyOn(DaemonClient.prototype, 'ensureConnected')
+ .mockResolvedValue()
+ const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
+ isNew: true,
+ pid: null,
+ shellState: 'unsupported',
+ snapshot: null
+ } as never)
+ const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 })
+ try {
+ await legacy.spawn({
+ sessionId: 'legacy-session',
+ cols: 80,
+ rows: 24,
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000
+ }
+ })
+ const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1]
+ expect(createPayload).not.toHaveProperty('startupIngress')
+ await expect(legacy.closeStartupQueryAuthority('legacy-session')).resolves.toBe(0)
+ expect(requestSpy).not.toHaveBeenCalledWith('closeStartupQueryAuthority', expect.anything())
+ } finally {
+ legacy.dispose()
+ requestSpy.mockRestore()
+ ensureConnectedSpy.mockRestore()
+ }
+ })
+
it('uses worktreeId as session prefix when provided', async () => {
const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' })
expect(result.id).toContain('wt-1')
diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts
index 0fe8ecc2a4d..81e91f16c63 100644
--- a/src/main/daemon/daemon-pty-adapter.ts
+++ b/src/main/daemon/daemon-pty-adapter.ts
@@ -14,6 +14,7 @@ import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
+ supportsPtyStartupIngress,
type CreateOrAttachResult,
type DaemonEvent,
type GetSnapshotResult,
@@ -105,6 +106,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
id: string
data: string
sequenceChars?: number
+ transformed?: boolean
+ seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
@@ -142,6 +145,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// must never see them, so gating makes them silent no-ops there.
private supportsProducerFlowControl: boolean
private supportsAuthoritativeBufferSnapshots: boolean
+ private supportsStartupIngress: boolean
private pausedProducerSessionIds = new Set()
// Why tracked here: the daemon's background set (keep-tail stream thinning
// + transient-fact scan authority) dies with the daemon process/socket;
@@ -185,6 +189,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20
+ this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion)
this.client.onDisconnected(() => {
for (const id of this.pausedProducerSessionIds) {
this.producerResumesOwedOnReconnect.add(id)
@@ -299,7 +304,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation,
shellReadySupported,
...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}),
- ...(historySeed ? { historySeed } : {})
+ ...(historySeed ? { historySeed } : {}),
+ ...(this.supportsStartupIngress && opts.startupIngress
+ ? { startupIngress: opts.startupIngress }
+ : {})
})
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
@@ -925,7 +933,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
onData(
- callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
+ callback: (payload: {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+ }) => void
): () => void {
this.dataListeners.push(callback)
return () => {
@@ -1455,9 +1469,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
listener({
id: event.sessionId,
data: event.payload.data,
- ...(event.payload.sequenceChars === undefined
+ ...((event.payload.rawLength ?? event.payload.sequenceChars) === undefined
? {}
- : { sequenceChars: event.payload.sequenceChars })
+ : { sequenceChars: event.payload.rawLength ?? event.payload.sequenceChars }),
+ ...(event.payload.transformed ? { transformed: true } : {}),
+ ...(event.payload.seq === undefined ? {} : { seq: event.payload.seq })
})
}
} else if (event.event === 'sessionBackgroundMarker') {
@@ -1518,6 +1534,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
})
}
+
+ async closeStartupQueryAuthority(id: string): Promise {
+ if (!this.supportsStartupIngress) {
+ return 0
+ }
+ const result = await this.client.request<{ appliedSeq: number }>('closeStartupQueryAuthority', {
+ sessionId: id
+ })
+ return result.appliedSeq
+ }
}
// Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is
diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts
index f8cad04b43b..a726b4000d9 100644
--- a/src/main/daemon/daemon-pty-router.ts
+++ b/src/main/daemon/daemon-pty-router.ts
@@ -17,6 +17,8 @@ export class DaemonPtyRouter implements IPtyProvider {
id: string
data: string
sequenceChars?: number
+ transformed?: boolean
+ seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
@@ -143,6 +145,10 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.adapterFor(id).clearBuffer(id)
}
+ async closeStartupQueryAuthority(id: string): Promise {
+ return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0
+ }
+
acknowledgeDataEvent(id: string, charCount: number): void {
this.adapterFor(id).acknowledgeDataEvent(id, charCount)
}
@@ -183,7 +189,13 @@ export class DaemonPtyRouter implements IPtyProvider {
}
onData(
- callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
+ callback: (payload: {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+ }) => void
): () => void {
this.dataListeners.push(callback)
return () => {
diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts
index 15ec9fecd21..93ee537c126 100644
--- a/src/main/daemon/daemon-server.ts
+++ b/src/main/daemon/daemon-server.ts
@@ -23,6 +23,8 @@ import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
import { isTuiAgent } from '../../shared/tui-agent-config'
+import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
+import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority'
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
@@ -707,11 +709,18 @@ export class DaemonServer {
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
shellReadySupported: p.shellReadySupported,
historySeed: p.historySeed,
+ startupIngress: parsePtyStartupIngressIntent(p.startupIngress, {
+ allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({
+ connectionId: null,
+ cwd: p.cwd,
+ shellOverride: p.shellOverride
+ })
+ }),
...(p.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
streamClient: {
- onData: (data) => {
+ onData: (data, rawLength = data.length, transformed = false, seq) => {
// Scan BEFORE enqueue: the batcher may keep-tail drop this
// chunk, but its facts must be captured regardless.
this.transientFactRelay.onSessionData(p.sessionId, data)
@@ -722,7 +731,10 @@ export class DaemonServer {
performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
flushImmediately: isInteractiveOutput,
- flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS
+ flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS,
+ rawLength,
+ transformed,
+ seq
})
},
onExit: (code) => {
diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts
index 32d85ff87b0..12eec59f758 100644
--- a/src/main/daemon/daemon-stream-data-batcher.test.ts
+++ b/src/main/daemon/daemon-stream-data-batcher.test.ts
@@ -77,6 +77,23 @@ describe('DaemonStreamDataBatcher', () => {
}
})
+ it('preserves transformed span metadata through an immediate flush', () => {
+ const { batcher, streamSocket } = createBatcher()
+
+ batcher.enqueue('client-1', 'session-1', '', {
+ flushImmediately: true,
+ rawLength: 9,
+ seq: 17,
+ transformed: true
+ })
+
+ expect(streamSocket.write).toHaveBeenCalledTimes(1)
+ expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({
+ event: 'data',
+ payload: { data: '', rawLength: 9, seq: 17, sequenceChars: 9, transformed: true }
+ })
+ })
+
it('keeps large pending output batched even when an interactive redraw follows', () => {
vi.useFakeTimers()
try {
diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts
index 84f7465b913..b27c4020217 100644
--- a/src/main/daemon/daemon-stream-data-batcher.ts
+++ b/src/main/daemon/daemon-stream-data-batcher.ts
@@ -13,6 +13,7 @@ import {
type PendingStreamDataBatch
} from './daemon-stream-keep-tail-drop'
import type { DaemonEvent } from './types'
+import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry'
type StreamDataClient = {
streamSocket: Socket | null
@@ -59,11 +60,6 @@ const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
// sessions × this ≈ tens of KB.
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
-type EnqueueOptions = {
- flushImmediately?: boolean
- flushMaxChars?: number
-}
-
type DaemonStreamDataBatcherOptions = {
maxLineBytes?: number
/** Fires after each stream-socket write — the only place backlog grows, so
@@ -97,26 +93,19 @@ export class DaemonStreamDataBatcher {
this.salvageDroppedData = options.salvageDroppedData ?? (() => '')
}
- enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void {
+ enqueue(
+ clientId: string,
+ sessionId: string,
+ data: string,
+ options: DaemonStreamEnqueueOptions = {}
+ ): void {
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
return
}
const batch = this.getOrCreateBatch(clientId)
- const last = batch.queue.at(-1)
- // Never coalesce across a control entry — it marks a position in the
- // session's byte order.
- if (last?.sessionId === sessionId && !last.control) {
- last.data += data
- } else {
- batch.queue.push({ sessionId, data })
- }
- batch.queuedChars += data.length
- batch.queuedCharsBySession.set(
- sessionId,
- (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length
- )
+ appendDaemonStreamData(batch, sessionId, data, options)
if (this.isSessionDroppable(sessionId)) {
// Keep-tail scales down as more backgrounded sessions queue, bounding
@@ -256,12 +245,16 @@ export class DaemonStreamDataBatcher {
})
}
const end =
- entry.data.length <= BULK_WRITE_SLICE_CHARS
+ entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS
? entry.data.length
: clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS)
const slice = entry.data.slice(0, end)
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
- const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length
+ const sliceSequenceChars = entry.transformed
+ ? entrySequenceChars
+ : entrySequenceChars === 0
+ ? 0
+ : slice.length
if (end >= entry.data.length) {
batch.queue.shift()
} else {
@@ -278,7 +271,15 @@ export class DaemonStreamDataBatcher {
} else {
batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter)
}
- writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars)
+ writeStreamDataEvents(
+ socket,
+ entry.sessionId,
+ slice,
+ this.maxLineBytes,
+ sliceSequenceChars,
+ entry.seq,
+ entry.transformed
+ )
this.onAfterSocketWrite?.()
}
if (retained.length > 0) {
@@ -369,7 +370,9 @@ export class DaemonStreamDataBatcher {
entry.sessionId,
entry.data,
this.maxLineBytes,
- entry.sequenceChars ?? entry.data.length
+ entry.sequenceChars ?? entry.data.length,
+ entry.seq,
+ entry.transformed
)
this.onAfterSocketWrite?.()
}
diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts
new file mode 100644
index 00000000000..6d44c6a49e8
--- /dev/null
+++ b/src/main/daemon/daemon-stream-data-entry.ts
@@ -0,0 +1,46 @@
+import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
+
+export type DaemonStreamEnqueueOptions = {
+ flushImmediately?: boolean
+ flushMaxChars?: number
+ rawLength?: number
+ transformed?: boolean
+ seq?: number
+}
+
+export function appendDaemonStreamData(
+ batch: PendingStreamDataBatch,
+ sessionId: string,
+ data: string,
+ options: DaemonStreamEnqueueOptions
+): void {
+ const last = batch.queue.at(-1)
+ // Why: control and transformed spans mark indivisible source-stream positions.
+ if (
+ last?.sessionId === sessionId &&
+ !last.control &&
+ !last.transformed &&
+ options.transformed !== true
+ ) {
+ last.data += data
+ const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
+ const combinedRawLength = rawLengthBefore + (options.rawLength ?? data.length)
+ last.sequenceChars = combinedRawLength === last.data.length ? undefined : combinedRawLength
+ last.seq = options.seq
+ } else {
+ batch.queue.push({
+ sessionId,
+ data,
+ ...(options.rawLength === undefined || options.rawLength === data.length
+ ? {}
+ : { sequenceChars: options.rawLength }),
+ ...(options.transformed ? { transformed: true } : {}),
+ ...(options.seq === undefined ? {} : { seq: options.seq })
+ })
+ }
+ batch.queuedChars += data.length
+ batch.queuedCharsBySession.set(
+ sessionId,
+ (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length
+ )
+}
diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts
index 0e15cf49523..986aeb7abca 100644
--- a/src/main/daemon/daemon-stream-data-split.ts
+++ b/src/main/daemon/daemon-stream-data-split.ts
@@ -9,18 +9,26 @@ import type { Socket } from 'node:net'
export function encodeStreamDataEvent(
sessionId: string,
data: string,
- sequenceChars?: number
+ rawLength?: number,
+ seq?: number,
+ transformed?: boolean
): string {
return encodeNdjson({
type: 'event',
event: 'data',
sessionId,
- payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }
+ payload: {
+ data,
+ ...(seq === undefined ? {} : { seq }),
+ ...(rawLength === undefined ? {} : { rawLength }),
+ ...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
+ ...(transformed ? { transformed: true } : {})
+ }
})
}
-function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number {
- return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8')
+function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number {
+ return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8')
}
function isHighSurrogate(value: number): boolean {
@@ -100,15 +108,27 @@ export function writeStreamDataEvents(
sessionId: string,
data: string,
maxLineBytes: number,
- sequenceChars = data.length
+ rawLength = data.length,
+ seq?: number,
+ transformed = false
): void {
- const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars
- for (const chunk of splitStreamDataForNdjson(
+ const explicitRawLength = rawLength === data.length ? undefined : rawLength
+ if (transformed) {
+ streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true))
+ return
+ }
+ const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
+ const chunks = splitStreamDataForNdjson(
sessionId,
data,
- maxLineBytes,
- explicitSequenceChars
- )) {
- streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars))
+ carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes,
+ explicitRawLength
+ )
+ let consumed = 0
+ for (const chunk of chunks) {
+ consumed += chunk.length
+ const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
+ const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
+ streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq))
}
}
diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts
index 1644642bd09..b47e7413992 100644
--- a/src/main/daemon/daemon-stream-events.ts
+++ b/src/main/daemon/daemon-stream-events.ts
@@ -5,7 +5,14 @@ export type DataEvent = {
type: 'event'
event: 'data'
sessionId: string
- payload: { data: string; sequenceChars?: number }
+ payload: {
+ data: string
+ seq?: number
+ rawLength?: number
+ transformed?: boolean
+ /** Legacy v23 name retained for old adapter fixtures. */
+ sequenceChars?: number
+ }
}
export type ExitEvent = {
diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts
index 0dc57e53522..0ef7bd0aab9 100644
--- a/src/main/daemon/daemon-stream-keep-tail-drop.ts
+++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts
@@ -21,6 +21,8 @@ export type StreamQueueEntry = {
/** Original PTY characters represented by data. Salvaged query copies are
* delivered bytes but represent zero new positions in the source stream. */
sequenceChars?: number
+ seq?: number
+ transformed?: boolean
control?: DaemonEvent
}
diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts
index 572cb0aca37..591c2cfa53d 100644
--- a/src/main/daemon/degraded-daemon-pty-provider.ts
+++ b/src/main/daemon/degraded-daemon-pty-provider.ts
@@ -3,6 +3,7 @@ import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shu
import type {
IPtyProvider,
PtyBackgroundStreamEvent,
+ PtyDataEvent,
PtyProviderBufferSnapshot,
PtyProcessInfo,
PtySpawnOptions,
@@ -26,11 +27,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
private fallback: ManagedPtyProvider
private sessionProviders = new Map()
private unsubscribers: (() => void)[] = []
- private dataListeners: ((payload: {
- id: string
- data: string
- sequenceChars?: number
- }) => void)[] = []
+ private dataListeners: ((payload: PtyDataEvent) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
constructor(opts: {
@@ -148,6 +145,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
await this.providerFor(id).clearBuffer(id)
}
+ async closeStartupQueryAuthority(id: string): Promise {
+ return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0
+ }
+
acknowledgeDataEvent(id: string, charCount: number): void {
this.providerFor(id).acknowledgeDataEvent(id, charCount)
}
@@ -187,9 +188,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return this.fallback.getProfiles()
}
- onData(
- callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
- ): () => void {
+ onData(callback: (payload: PtyDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts
index 3b8f068868c..b5446524ebf 100644
--- a/src/main/daemon/session.test.ts
+++ b/src/main/daemon/session.test.ts
@@ -106,6 +106,11 @@ describe('Session', () => {
cols?: number
rows?: number
launchAgent?: TuiAgent
+ startupIngress?: {
+ colors: { foreground: string; background: string }
+ deadlineMs: number
+ echoProjection?: 'windows-conpty-esc-stripped'
+ }
wslDistro?: string
}): Session {
session = new Session({
@@ -116,6 +121,7 @@ describe('Session', () => {
wslDistro: opts?.wslDistro,
subprocess,
shellReadySupported: opts?.shellReadySupported ?? false,
+ ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}),
...(opts?.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
: {})
@@ -187,6 +193,56 @@ describe('Session', () => {
expect(received1).toEqual(['broadcast'])
expect(received2).toEqual(['broadcast'])
})
+
+ it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => {
+ createSession({
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000,
+ echoProjection: 'windows-conpty-esc-stripped'
+ }
+ })
+ const onData = vi.fn()
+ session.attachClient({ onData, onExit: () => {} })
+ const query = '\x1b]10;?\x07'
+ const echo = ']10;rgb:2e2e/3434/3434\\'
+
+ subprocess.simulateData(query)
+ subprocess.simulateData(echo)
+ subprocess.simulateData('prompt')
+
+ expect(subprocess.written).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\'])
+ expect(onData.mock.calls).toEqual([
+ ['', query.length, true, query.length],
+ ['', echo.length, true, query.length + echo.length],
+ ['prompt']
+ ])
+ expect(session.takePendingOutput(false)?.records).toEqual([
+ { kind: 'output', data: 'prompt' }
+ ])
+ expect(session.getSnapshot()).toMatchObject({
+ outputSequence: query.length + echo.length + 'prompt'.length
+ })
+ expect(session.getSnapshot()?.snapshotAnsi).toContain('prompt')
+ expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
+ })
+
+ it('releases a held cooked-echo prefix before taking a snapshot', () => {
+ createSession({
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000,
+ echoProjection: 'windows-conpty-esc-stripped'
+ }
+ })
+ subprocess.simulateData('\x1b]10;?\x07')
+ subprocess.simulateData(']10;rgb:2e2e/')
+
+ const snapshot = session.getSnapshot()
+
+ expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/')
+ expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length)
+ })
})
describe('write', () => {
diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts
index 7ca52cf0c2a..1c414046cb3 100644
--- a/src/main/daemon/session.ts
+++ b/src/main/daemon/session.ts
@@ -12,6 +12,11 @@ import { isPowerShellProcess } from '../../shared/shell-process-detection'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { TuiAgent } from '../../shared/types'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
+import {
+ PtyStartupIngress,
+ type PtyIngressEmission,
+ type PtyStartupIngressIntent
+} from '../../shared/pty-startup-ingress'
import type {
PendingOutputRecord,
SessionState,
@@ -97,11 +102,12 @@ export type SessionOptions = {
// reaper, dead sessions (and their ~5000-row scrollback emulators) accumulate
// for the lifetime of the long-lived daemon process.
onExit?: (code: number) => void
+ startupIngress?: PtyStartupIngressIntent
}
type AttachedClient = {
token: symbol
- onData: (data: string) => void
+ onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
onExit: (code: number) => void
}
@@ -135,6 +141,7 @@ export class Session {
private forceKillSent = false
private subprocessDisposed = false
private readonly physicalExit = new PhysicalExitTracker()
+ private readonly startupIngress: PtyStartupIngress
constructor(opts: SessionOptions) {
this.sessionId = opts.sessionId
@@ -170,6 +177,11 @@ export class Session {
}
this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue())
+ this.startupIngress = new PtyStartupIngress({
+ ...(opts.startupIngress ? { intent: opts.startupIngress } : {}),
+ write: (data) => this.subprocess.write(data),
+ onEmission: (emission) => this.emitSubprocessOutput(emission)
+ })
this.subprocess.onData((data) => this.handleSubprocessData(data))
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
}
@@ -389,7 +401,7 @@ export class Session {
this.subprocess.signal(sig)
}
- attachClient(client: { onData: (data: string) => void; onExit: (code: number) => void }): symbol {
+ attachClient(client: Omit): symbol {
const token = Symbol('attach')
this.attachedClients.push({ token, ...client })
return token
@@ -413,6 +425,7 @@ export class Session {
}
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
+ this.startupIngress.snapshotBarrier()
if (this._disposed) {
return null
}
@@ -518,7 +531,9 @@ export class Session {
}
prepareForFinalSnapshot(): string {
- return this.releaseHeldShellReadyBytes()
+ const held = this.releaseHeldShellReadyBytes()
+ this.startupIngress.snapshotBarrier()
+ return held
}
dispose(): void {
@@ -533,6 +548,8 @@ export class Session {
// move this capture below #teardownSubprocess or the `_state = 'exited'`
// assignment — #teardownSubprocess flips `_disposed` but the invariant
// depends on the PRE-flip value of `_state`.
+ this.releaseHeldShellReadyBytes()
+ this.startupIngress.drainAndClose()
const wasTerminating = this._isTerminating && this._state !== 'exited'
const clientsToNotify = wasTerminating ? this.attachedClients.slice() : []
if (wasTerminating) {
@@ -667,25 +684,29 @@ export class Session {
this.postReadyFlushGate.notifyData()
}
- this.emitSubprocessOutput(data)
+ this.startupIngress.accept(data)
}
- private emitSubprocessOutput(data: string): void {
- if (data.length === 0) {
- return
- }
-
+ private emitSubprocessOutput(emission: PtyIngressEmission): void {
+ const { data } = emission
+ const rawLength = emission.rawEndSeq - emission.rawStartSeq
// Why: daemon stream thinning can omit bytes before main sees them. The
// absolute count lets an authoritative snapshot cover those gaps while
// renderer reconciliation deduplicates any queued post-snapshot tail.
- this.outputSequence += data.length
+ this.outputSequence += rawLength
// Feed data to headless emulator for state tracking
- this.emulator.write(data)
- this.recordPendingOutput({ kind: 'output', data })
+ if (data.length > 0) {
+ this.emulator.write(data)
+ this.recordPendingOutput({ kind: 'output', data })
+ }
// Broadcast to attached clients
for (const client of this.attachedClients) {
- client.onData(data)
+ if (emission.transformed || rawLength !== data.length) {
+ client.onData(data, rawLength, true, this.outputSequence)
+ } else {
+ client.onData(data)
+ }
}
}
@@ -695,13 +716,14 @@ export class Session {
return
}
+ this.releaseHeldShellReadyBytes()
+ this.startupIngress.drainAndClose()
this._exitCode = code
this._state = 'exited'
this._isTerminating = false
// Why resume:false — the child is reaped, so there is nothing to unblock;
// only the failsafe timer must not outlive the session.
this.releaseProducerPause({ resume: false })
- this.releaseHeldShellReadyBytes()
if (this.killTimer) {
clearTimeout(this.killTimer)
@@ -740,10 +762,14 @@ export class Session {
// bytes can be stripped. If readiness never completes, preserve the
// previous behavior by releasing any held prefix before timeout or exit
// state changes discard it.
- this.emitSubprocessOutput(heldBytes)
+ this.startupIngress.accept(heldBytes)
return heldBytes
}
+ closeStartupQueryAuthority(): number {
+ return this.startupIngress.closeQueryAuthority()
+ }
+
private transitionToReady(postMarkerBytesObserved = false): void {
this._shellState = 'ready'
this.shellReadyScanState = null
diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts
index 10cd5733cd3..47fba51b2c3 100644
--- a/src/main/daemon/terminal-host-create-contract.ts
+++ b/src/main/daemon/terminal-host-create-contract.ts
@@ -1,6 +1,7 @@
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../shared/types'
import type { ShellReadyState, TerminalSnapshot } from './types'
+import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
export type CreateOrAttachOptions = {
sessionId: string
@@ -19,7 +20,11 @@ export type CreateOrAttachOptions = {
shellReadySupported?: boolean
shellReadyTimeoutMs?: number
historySeed?: string
- streamClient: { onData: (data: string) => void; onExit: (code: number) => void }
+ startupIngress?: PtyStartupIngressIntent
+ streamClient: {
+ onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
+ onExit: (code: number) => void
+ }
}
export type CreateOrAttachResult = {
diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts
new file mode 100644
index 00000000000..a879a618c25
--- /dev/null
+++ b/src/main/daemon/terminal-host-options.ts
@@ -0,0 +1,27 @@
+import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
+import type { SubprocessHandle } from './session'
+import type { TakePendingOutputResult, TerminalSnapshot } from './types'
+
+export type TerminalHostOptions = {
+ spawnSubprocess: (opts: {
+ sessionId: string
+ cols: number
+ rows: number
+ cwd?: string
+ env?: Record
+ envToDelete?: string[]
+ command?: string
+ startupCommandDelivery?: StartupCommandDelivery
+ shellOverride?: string
+ terminalWindowsWslDistro?: string | null
+ terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
+ }) => SubprocessHandle
+ // Why: graceful shutdown checkpoints must finish in-process before teardown.
+ onFinalCheckpoint?: (
+ sessionId: string,
+ snapshot: TerminalSnapshot,
+ records: TakePendingOutputResult['records']
+ ) => void
+ // Why: tests need deterministic tombstone eviction without thousands of sessions.
+ maxTombstones?: number
+}
diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts
index ba15c6ed814..d8458aa81ad 100644
--- a/src/main/daemon/terminal-host.ts
+++ b/src/main/daemon/terminal-host.ts
@@ -1,8 +1,7 @@
-import { Session, type SubprocessHandle } from './session'
+import { Session } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import { resolveProcessCwd } from '../providers/process-cwd'
-import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import {
SessionNotFoundError,
@@ -11,42 +10,17 @@ import {
type TerminalSnapshot
} from './types'
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
+import type { TerminalHostOptions } from './terminal-host-options'
import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown'
import { TerminalSessionTeardown } from './terminal-session-teardown'
import { resolveWslSessionContext } from './wsl-session-context'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
+export type { TerminalHostOptions } from './terminal-host-options'
const DEFAULT_MAX_TOMBSTONES = 1000
-export type TerminalHostOptions = {
- spawnSubprocess: (opts: {
- sessionId: string
- cols: number
- rows: number
- cwd?: string
- env?: Record
- envToDelete?: string[]
- command?: string
- startupCommandDelivery?: StartupCommandDelivery
- shellOverride?: string
- terminalWindowsWslDistro?: string | null
- terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
- }) => SubprocessHandle
- // Why: on graceful shutdown, the host writes final checkpoints for all live
- // sessions before killing them. This bypasses the RPC round-trip — the daemon
- // writes checkpoints in-process, guaranteeing completion before teardown.
- onFinalCheckpoint?: (
- sessionId: string,
- snapshot: TerminalSnapshot,
- records: TakePendingOutputResult['records']
- ) => void
- // Why: production keeps a large cap, but tests need a small deterministic cap
- // without spawning thousands of full terminal sessions.
- maxTombstones?: number
-}
-
export class TerminalHost {
private sessions = new Map()
private sessionTeardown = new TerminalSessionTeardown(this.sessions)
@@ -147,6 +121,7 @@ export class TerminalHost {
subprocess,
shellReadySupported,
historySeed: opts.historySeed,
+ ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
wslDistro,
// Why: reap the dead session (dispose emulator + drop from the map) the
// moment its subprocess exits, instead of retaining it for the daemon's
@@ -197,6 +172,10 @@ export class TerminalHost {
this.getAliveSession(sessionId).write(data)
}
+ closeStartupQueryAuthority(sessionId: string): number {
+ return this.getAliveSession(sessionId).closeStartupQueryAuthority()
+ }
+
resize(sessionId: string, cols: number, rows: number): void {
this.getAliveSession(sessionId).resize(cols, rows)
}
diff --git a/src/main/daemon/terminal-modes.ts b/src/main/daemon/terminal-modes.ts
new file mode 100644
index 00000000000..41718b196fc
--- /dev/null
+++ b/src/main/daemon/terminal-modes.ts
@@ -0,0 +1,11 @@
+export type TerminalModes = {
+ bracketedPaste: boolean
+ mouseTracking: boolean
+ mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
+ sgrMouseMode?: boolean
+ sgrMousePixelsMode?: boolean
+ applicationCursor: boolean
+ alternateScreen: boolean
+ /** Kitty keyboard protocol flags used only to reseed a warm daemon emulator. */
+ kittyKeyboardFlags?: number
+}
diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts
new file mode 100644
index 00000000000..653d8fc4bd6
--- /dev/null
+++ b/src/main/daemon/terminal-snapshot.ts
@@ -0,0 +1,20 @@
+import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
+import type { TerminalModes } from './terminal-modes'
+
+export type TerminalSnapshot = {
+ snapshotAnsi: string
+ /** Parser tail is already counted by the snapshot sequence and must restore last. */
+ pendingEscapeTailAnsi?: string
+ /** Normal buffer captured separately while snapshotAnsi holds an alternate buffer. */
+ scrollbackAnsi: string
+ oscLinks?: TerminalOscLinkRange[]
+ rehydrateSequences: string
+ cwd: string | null
+ modes: TerminalModes
+ cols: number
+ rows: number
+ scrollbackLines: number
+ lastTitle?: string
+ /** Optional because persisted snapshots and older v19 daemons lack it. */
+ outputSequence?: number
+}
diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts
index 0daaf0ac0cf..5c148dbf238 100644
--- a/src/main/daemon/types.ts
+++ b/src/main/daemon/types.ts
@@ -1,4 +1,3 @@
-import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type {
ConfirmForegroundProcessRequest,
GetForegroundProcessRequest
@@ -12,67 +11,24 @@ export type {
// ─── Protocol Version ────────────────────────────────────────────────
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../shared/types'
-// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
-// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
-// Why: bump when adding daemon wire behavior so same-version old daemons do
-// not silently accept the handshake and then reject new RPCs.
-export const PROTOCOL_VERSION = 24
-export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
-export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
-export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
-] as const
+import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
+export type { TerminalModes } from './terminal-modes'
+import type { TerminalSnapshot } from './terminal-snapshot'
+export type { TerminalSnapshot } from './terminal-snapshot'
+export {
+ CLEAN_DISCONNECT_PROTOCOL_VERSION,
+ GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
+ PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
+ PROTOCOL_VERSION,
+ PTY_STARTUP_INGRESS_PROTOCOL_VERSION,
+ supportsPtyStartupIngress
+} from './daemon-protocol-version'
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported'
-// ─── Terminal Snapshot ──────────────────────────────────────────────
-export type TerminalSnapshot = {
- snapshotAnsi: string
- /** Trailing incomplete escape sequence the emulator ingested but xterm's
- * parser is still holding (a PTY read ended mid-escape). Restorers must
- * write this LAST — after their own post-replay resets, immediately before
- * post-snapshot live chunks — so the continuation bytes complete it
- * exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its
- * bytes are already counted by the snapshot seq. */
- pendingEscapeTailAnsi?: string
- /** Normal buffer captured separately while snapshotAnsi holds an active
- * alternate buffer. Empty for normal-screen snapshots. */
- scrollbackAnsi: string
- oscLinks?: TerminalOscLinkRange[]
- rehydrateSequences: string
- cwd: string | null
- modes: TerminalModes
- cols: number
- rows: number
- scrollbackLines: number
- lastTitle?: string
- /** Absolute UTF-16 character count ingested by this live daemon session.
- * Optional because persisted snapshots and older v19 daemons lack it. */
- outputSequence?: number
-}
-
-export type TerminalModes = {
- bracketedPaste: boolean
- mouseTracking: boolean
- mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
- sgrMouseMode?: boolean
- sgrMousePixelsMode?: boolean
- applicationCursor: boolean
- alternateScreen: boolean
- /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
- * parity ONLY. Consumed by the daemon warm-reattach path: the spawn
- * result threads them into seedHeadlessTerminal, which re-applies them to
- * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags)
- * so hidden `CSI ? u` answers the real flags instead of ?0u.
- * rehydrateSequences must never push these into a renderer xterm —
- * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative
- * (terminal-query-authority.md §kitty). */
- kittyKeyboardFlags?: number
-}
-
// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it
// depends only on TerminalModes here) — re-exported so existing importers of
// `./types` keep working.
@@ -115,9 +71,16 @@ export type CreateOrAttachRequest = {
shellReadyTimeoutMs?: number
/** Recovered ANSI applied before the new subprocess can emit startup output. */
historySeed?: string
+ startupIngress?: PtyStartupIngressIntent
}
}
+export type CloseStartupQueryAuthorityRequest = {
+ id: string
+ type: 'closeStartupQueryAuthority'
+ payload: { sessionId: string }
+}
+
export type CancelCreateOrAttachRequest = {
id: string
type: 'cancelCreateOrAttach'
@@ -337,6 +300,7 @@ export type DaemonRequest =
| GetSnapshotRequest
| GetSizeRequest
| TakePendingOutputRequest
+ | CloseStartupQueryAuthorityRequest
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index c8843bd5076..5fddd31c1f8 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -3852,7 +3852,8 @@ describe('registerPtyHandlers', () => {
result.id,
'daemon output',
expect.any(Number),
- 'daemon output'.length
+ 'daemon output'.length,
+ undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: result.id,
@@ -8257,14 +8258,17 @@ describe('registerPtyHandlers', () => {
mockProc.proc.write.mockClear()
mainWindow.webContents.send.mockClear()
- mockProc.emitData('\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready')
+ const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready'
+ mockProc.emitData(sourceData)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
- data: 'ready'
+ data: 'ready',
+ rawLength: sourceData.length,
+ transformed: true
})
} finally {
vi.useRealTimers()
@@ -8291,14 +8295,17 @@ describe('registerPtyHandlers', () => {
mockProc.proc.write.mockClear()
mainWindow.webContents.send.mockClear()
- mockProc.emitData('\x1b]10;?;?\x1b\\ready')
+ const sourceData = '\x1b]10;?;?\x1b\\ready'
+ mockProc.emitData(sourceData)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\')
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
- data: 'ready'
+ data: 'ready',
+ rawLength: sourceData.length,
+ transformed: true
})
} finally {
vi.useRealTimers()
@@ -8371,13 +8378,28 @@ describe('registerPtyHandlers', () => {
}
})
- it('answers daemon agent startup OSC color queries before spawn resolves', async () => {
+ it('accepts source-classified daemon startup spans before spawn resolves', async () => {
vi.useFakeTimers()
- let dataHandler: ((payload: { id: string; data: string }) => void) | null = null
+ type ProviderData = {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+ }
+ let dataHandler: ((payload: ProviderData) => void) | null = null
const write = vi.fn()
- const spawn = vi.fn(async (options: { sessionId?: string }) => {
+ const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\'
+ const spawn = vi.fn(async (options: { sessionId?: string; startupIngress?: unknown }) => {
const id = options.sessionId ?? 'daemon-pty'
- dataHandler?.({ id, data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\daemon-ready' })
+ dataHandler?.({
+ id,
+ data: '',
+ sequenceChars: query.length,
+ transformed: true,
+ seq: query.length
+ })
+ dataHandler?.({ id, data: 'daemon-ready' })
return { id }
})
setLocalPtyProvider({
@@ -8395,7 +8417,7 @@ describe('registerPtyHandlers', () => {
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
- onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => {
+ onData: vi.fn((handler: (payload: ProviderData) => void) => {
dataHandler = handler
return () => {}
}),
@@ -8408,7 +8430,16 @@ describe('registerPtyHandlers', () => {
} as never)
try {
- registerPtyHandlers(mainWindow as never)
+ let seq = 0
+ const runtime = {
+ setPtyController: vi.fn(),
+ createPreAllocatedTerminalHandle: vi.fn(() => null),
+ onPtyData: vi.fn(
+ (_id: string, _data: string, _at: number, rawLength: number) => (seq += rawLength)
+ ),
+ registerPty: vi.fn()
+ }
+ registerPtyHandlers(mainWindow as never, runtime as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
@@ -8420,22 +8451,39 @@ describe('registerPtyHandlers', () => {
}
})) as { id: string }
- expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
- expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\')
+ expect(spawn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ startupIngress: expect.objectContaining({
+ colors: { foreground: '#eeeeee', background: '#111111' },
+ deadlineMs: 5_000
+ })
+ })
+ )
+ expect(write).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
- data: 'daemon-ready'
+ data: 'daemon-ready',
+ seq: query.length + 'daemon-ready'.length,
+ rawLength: query.length + 'daemon-ready'.length,
+ transformed: true
})
} finally {
vi.useRealTimers()
}
})
- it('drops renderer sequence metadata when an answered OSC query is batched', async () => {
+ it('preserves source raw sequence metadata when a consumed query is batched', async () => {
vi.useFakeTimers()
+ type ProviderData = {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+ }
const providerEvents: {
- dataHandler?: (payload: { id: string; data: string }) => void
+ dataHandler?: (payload: ProviderData) => void
} = {}
const write = vi.fn()
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
@@ -8456,7 +8504,7 @@ describe('registerPtyHandlers', () => {
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
- onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => {
+ onData: vi.fn((handler: (payload: ProviderData) => void) => {
providerEvents.dataHandler = handler
return () => {}
}),
@@ -8471,8 +8519,8 @@ describe('registerPtyHandlers', () => {
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
- onPtyData: vi.fn((_id: string, data: string) => {
- seq += data.length
+ onPtyData: vi.fn((_id: string, data: string, _at: number, rawLength = data.length) => {
+ seq += rawLength
return seq
}),
registerPty: vi.fn()
@@ -8493,17 +8541,24 @@ describe('registerPtyHandlers', () => {
mainWindow.webContents.send.mockClear()
providerEvents.dataHandler?.({ id: spawnResult.id, data: 'prefix' })
+ const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\'
providerEvents.dataHandler?.({
id: spawnResult.id,
- data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready'
+ data: '',
+ sequenceChars: query.length,
+ transformed: true,
+ seq: 'prefix'.length + query.length
})
+ providerEvents.dataHandler?.({ id: spawnResult.id, data: 'ready' })
vi.advanceTimersByTime(2)
- expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\')
- expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\')
+ expect(write).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
- data: 'prefixready'
+ data: 'prefixready',
+ seq: 'prefix'.length + query.length + 'ready'.length,
+ rawLength: 'prefix'.length + query.length + 'ready'.length,
+ transformed: true
})
} finally {
vi.useRealTimers()
@@ -9925,7 +9980,8 @@ describe('registerPtyHandlers', () => {
result.id,
'hidden output',
expect.any(Number),
- 'hidden output'.length
+ 'hidden output'.length,
+ undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why out-of-band: an in-band empty pty:data chunk is ambiguous with
@@ -10477,7 +10533,8 @@ describe('registerPtyHandlers', () => {
'daemon-session',
'pre-spawn prompt\x1b[c',
expect.any(Number),
- 'pre-spawn prompt\x1b[c'.length
+ 'pre-spawn prompt\x1b[c'.length,
+ undefined
)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 8c6b89d0d48..956383b7651 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -149,13 +149,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
-import {
- answerStartupTerminalColorQueries,
- clearStartupTerminalColorQueryReplies,
- getStartupTerminalColorQueryReplyColors,
- moveStartupTerminalColorQueryReplies,
- registerStartupTerminalColorQueryReplies
-} from './terminal-startup-color-query-replies'
+import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus
@@ -459,20 +453,14 @@ function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined {
}
}
-function getProviderForStartupTerminalColorReply(ptyId: string): IPtyProvider | undefined {
- const ownedConnectionId = ptyOwnership.get(ptyId)
- if (ownedConnectionId !== undefined) {
- return getProvider(ownedConnectionId)
+function closeStartupQueryAuthorityForPty(ptyId: string): void {
+ try {
+ void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch(
+ () => {}
+ )
+ } catch {
+ /* Best-effort handoff; the bounded source deadline remains the fallback. */
}
- const parsedSshId = parseAppSshPtyId(ptyId)
- if (parsedSshId) {
- return getProvider(parsedSshId.connectionId)
- }
- return localProvider
-}
-
-export function answerStartupTerminalColorQueriesForPty(ptyId: string, data: string): string {
- return answerStartupTerminalColorQueries(ptyId, data, getProviderForStartupTerminalColorReply)
}
function normalizeNodePtySpawnError(err: unknown): Error {
@@ -1189,7 +1177,6 @@ export function clearProviderPtyState(id: string): void {
rendererVisibilityKnownPtys.delete(id)
pendingHiddenRendererResizeOutputPtys.delete(id)
deliveredHiddenRendererResizeOutputPtys.delete(id)
- clearStartupTerminalColorQueryReplies(id)
// Why: every PTY teardown path funnels through here (local exit, daemon
// shutdown, SSH exit/connection teardown) — hidden/interest gate bits must
// not outlive the PTY or a reused map entry could silently gate a new one.
@@ -1637,7 +1624,8 @@ export function registerPtyHandlers(
markClaudePtyExited(id)
runtime?.onPtyExit(id, code)
},
- onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp)
+ onData: (id, data, timestamp, sequenceChars, transformed) =>
+ runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
})
}
@@ -1648,6 +1636,8 @@ export function registerPtyHandlers(
type PendingPtyData = {
data: string
startSeq?: number
+ rawLength?: number
+ transformed?: true
containsBackgroundOutput?: boolean
// Why droppedOutput (not main's droppedBacklog trim): this branch bounds
// the unsent backlog with the O(1) drop-to-sentinel + query-salvage +
@@ -1662,6 +1652,7 @@ export function registerPtyHandlers(
data: string
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
droppedOutput?: boolean
}
@@ -2057,20 +2048,23 @@ export function registerPtyHandlers(
return true
}
- function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined {
- return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined
- }
-
function makePtyDataPayload(
id: string,
data: string,
startSeq: number | undefined,
- containsBackgroundOutput: boolean | undefined
+ containsBackgroundOutput: boolean | undefined,
+ rawLength = data.length,
+ transformed = false
): PtyDataPayload {
const payload: PtyDataPayload = { id, data }
if (typeof startSeq === 'number') {
- payload.seq = startSeq + data.length
- payload.rawLength = data.length
+ payload.seq = startSeq + rawLength
+ }
+ if (typeof startSeq === 'number' || rawLength !== data.length || transformed) {
+ payload.rawLength = rawLength
+ }
+ if (transformed) {
+ payload.transformed = true
}
if (containsBackgroundOutput === true) {
payload.background = true
@@ -2362,7 +2356,9 @@ export function registerPtyHandlers(
data: string,
startSeq: number | undefined,
preservesSeq: boolean,
- containsBackgroundOutput: boolean
+ containsBackgroundOutput: boolean,
+ rawLength = data.length,
+ transformed = false
): PendingPtyData {
// Why: once over the cap, stay dropped at O(1) memory until the renderer
// can receive again — the restore sentinel supersedes any interim bytes.
@@ -2377,21 +2373,21 @@ export function registerPtyHandlers(
}
const nextContainsBackgroundOutput =
existing?.containsBackgroundOutput === true || containsBackgroundOutput
- if (!preservesSeq) {
- return dropOversizedPendingPtyData(id, {
- data: (existing?.data ?? '') + data,
- ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
- })
- }
if (!existing) {
return dropOversizedPendingPtyData(id, {
data,
...(typeof startSeq === 'number' ? { startSeq } : {}),
+ ...(rawLength !== data.length ? { rawLength } : {}),
+ ...(transformed ? { transformed: true } : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
})
}
+ const existingRawLength = existing.rawLength ?? existing.data.length
const next: PendingPtyData = {
data: existing.data + data,
+ ...(!preservesSeq || existing.transformed || transformed
+ ? { rawLength: existingRawLength + rawLength, transformed: true as const }
+ : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
if (typeof existing.startSeq === 'number') {
@@ -2490,8 +2486,9 @@ export function registerPtyHandlers(
continue
}
const { data } = pending
- const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
- const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
+ const indivisible = pending.transformed === true
+ const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
+ const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
if (remaining) {
const nextPending: PendingPtyData = { data: remaining }
if (typeof pending.startSeq === 'number') {
@@ -2507,7 +2504,14 @@ export function registerPtyHandlers(
updateProducerFlowControl(id)
sendPtyDataToRenderer(
id,
- makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput)
+ makePtyDataPayload(
+ id,
+ chunk,
+ pending.startSeq,
+ pending.containsBackgroundOutput,
+ pending.rawLength,
+ pending.transformed
+ )
)
writes++
}
@@ -2580,7 +2584,9 @@ export function registerPtyHandlers(
payload.id,
remaining.data,
remaining.startSeq,
- remaining.containsBackgroundOutput
+ remaining.containsBackgroundOutput,
+ remaining.rawLength,
+ remaining.transformed
)
)
}
@@ -2668,19 +2674,14 @@ export function registerPtyHandlers(
const isLocalProvider = localProvider instanceof LocalPtyProvider
localDataUnsub = localProvider.onData((payload) => {
+ const rawLength = payload.sequenceChars ?? payload.data.length
const outputSeq = isLocalProvider
? runtime?.getPtyOutputSequence(payload.id)
- : runtime?.onPtyData(
- payload.id,
- payload.data,
- Date.now(),
- payload.sequenceChars ?? payload.data.length
- )
- const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data)
- const preservesSeq =
- rendererData === payload.data &&
- (payload.sequenceChars === undefined || payload.sequenceChars === payload.data.length)
- const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined
+ : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
+ const rendererData = payload.data
+ const preservesSeq = !payload.transformed && rawLength === payload.data.length
+ const startSeq =
+ typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
if (mainWindow.isDestroyed()) {
// Why: clear the pending flush timer so it doesn't fire after the window
// is gone. Without this, macOS app re-activation leaks orphaned timers
@@ -2713,7 +2714,7 @@ export function registerPtyHandlers(
}
return
}
- if (rendererData.length === 0) {
+ if (rendererData.length === 0 && !payload.transformed) {
return
}
const containsBackgroundOutput =
@@ -2728,7 +2729,9 @@ export function registerPtyHandlers(
rendererData,
startSeq,
preservesSeq,
- containsBackgroundOutput
+ containsBackgroundOutput,
+ rawLength,
+ payload.transformed === true
)
const nextData = pending.data
const isInteractiveOutput = shouldSendInteractiveOutputNow(
@@ -2760,8 +2763,12 @@ export function registerPtyHandlers(
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
- ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length }
+ ? {
+ seq: pending.startSeq + (pending.rawLength ?? nextData.length),
+ rawLength: pending.rawLength ?? nextData.length
+ }
: {}),
+ ...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
})
@@ -3904,10 +3911,6 @@ export function registerPtyHandlers(
})?.distro ?? null)
: null
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
- const preSpawnStartupTerminalColorReplyPtyId =
- startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined
- ? (effectiveSessionAppId ?? effectiveSessionId)
- : null
// Why: the renderer sets pane env for SSH too. Only forward it to the
// remote when the relay hook path is enabled; otherwise a newer relay
// could emit statuses this Orca build is not prepared to route.
@@ -4015,6 +4018,11 @@ export function registerPtyHandlers(
const validatedLeafId = verifiedLeafId ?? metadataLeafId
let env: Record | undefined = baseEnv
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
+ const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({
+ connectionId: args.connectionId,
+ cwd: args.cwd,
+ shellOverride: effectiveShellOverride
+ })
const codexSelectionTarget = getCodexSelectionTargetForPty(
effectiveShellOverride,
cwd,
@@ -4169,6 +4177,15 @@ export function registerPtyHandlers(
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
+ if (startupTerminalColorQueryReplyColors) {
+ spawnOptions.startupIngress = {
+ colors: startupTerminalColorQueryReplyColors,
+ deadlineMs: 5_000,
+ ...(nativeWindowsConptySpawn
+ ? { echoProjection: 'windows-conpty-esc-stripped' as const }
+ : {})
+ }
+ }
const existingPaneSpawn = reservationPaneKey
? paneSpawnReservationsByPaneKey.get(reservationPaneKey)
: undefined
@@ -4202,14 +4219,6 @@ export function registerPtyHandlers(
if (preAllocatedHandle) {
trustedTerminalHandleEnv.add(preAllocatedHandle)
}
- if (preSpawnStartupTerminalColorReplyPtyId && startupTerminalColorQueryReplyColors) {
- // Why: Codex probes OSC 10/11 with a 100 ms timeout and daemon PTYs
- // can emit that query before spawn() resolves to the renderer.
- registerStartupTerminalColorQueryReplies(
- preSpawnStartupTerminalColorReplyPtyId,
- startupTerminalColorQueryReplyColors
- )
- }
spawnTiming.mark('options')
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
if (isDaemonHostSpawn && expectedPtyId) {
@@ -4254,9 +4263,6 @@ export function registerPtyHandlers(
const spawnError = normalizeNodePtySpawnError(err)
const isIdentityMismatch =
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
- if (preSpawnStartupTerminalColorReplyPtyId) {
- clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId)
- }
if (effectiveSessionAppId !== undefined) {
if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) {
ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach)
@@ -4337,33 +4343,14 @@ export function registerPtyHandlers(
// ownership, and a hidden-spawned agent should be paceable from its
// first flood, not from its first visibility transition.
syncPtyBackgroundedDelivery(result.id, 'spawn')
+ closeStartupQueryAuthorityForPty(result.id)
}
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
// determination from the spawn record before the headless seed below,
// so the runtime emulator's DA1 override exists from byte zero.
- if (
- isNativeWindowsLocalPtySpawn({
- connectionId: args.connectionId,
- cwd: args.cwd,
- shellOverride: effectiveShellOverride
- })
- ) {
+ if (nativeWindowsConptySpawn) {
markNativeWindowsConptyPty(result.id)
}
- if (startupTerminalColorQueryReplyColors) {
- if (result.isReattach) {
- if (preSpawnStartupTerminalColorReplyPtyId) {
- clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId)
- }
- } else if (preSpawnStartupTerminalColorReplyPtyId) {
- moveStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId, result.id)
- } else {
- registerStartupTerminalColorQueryReplies(
- result.id,
- startupTerminalColorQueryReplyColors
- )
- }
- }
const relayResultId = getRelayPtyId(args.connectionId, result.id)
if (store && args.connectionId) {
// Why: remote PTYs live in the SSH relay grace window after Orca
@@ -5085,6 +5072,7 @@ export function registerPtyHandlers(
rendererVisibilityKnownPtys.add(args.id)
if (args.visible) {
visibleRendererPtys.add(args.id)
+ closeStartupQueryAuthorityForPty(args.id)
} else {
visibleRendererPtys.delete(args.id)
}
@@ -5101,6 +5089,7 @@ export function registerPtyHandlers(
})
if (args.hidden === true) {
markHiddenRendererPty(args.id)
+ closeStartupQueryAuthorityForPty(args.id)
// Why: bytes already queued for a newly hidden PTY are model-owned
// state; drop them now instead of holding them under ACK starvation.
// Reveal restores from the snapshot.
diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts
index c925f592aa2..cbca2db9d2a 100644
--- a/src/main/ipc/ssh.test.ts
+++ b/src/main/ipc/ssh.test.ts
@@ -161,7 +161,6 @@ vi.mock('./pty', () => ({
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
- answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
isRendererPtyOutputPaused: vi.fn().mockReturnValue(false)
@@ -809,7 +808,13 @@ describe('SSH IPC handlers', () => {
onData?.({ id: 'remote-pty', data: 'hello' })
onExit?.({ id: 'remote-pty', code: 7 })
- expect(runtime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number))
+ expect(runtime.onPtyData).toHaveBeenCalledWith(
+ 'remote-pty',
+ 'hello',
+ expect.any(Number),
+ 'hello'.length,
+ undefined
+ )
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
})
@@ -1180,7 +1185,13 @@ describe('SSH IPC handlers', () => {
targetId: 'ssh-1',
ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })])
})
- expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number))
+ expect(secondRuntime.onPtyData).toHaveBeenCalledWith(
+ 'remote-pty',
+ 'hello',
+ expect.any(Number),
+ 'hello'.length,
+ undefined
+ )
expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9)
expect(firstRuntime.onPtyData).not.toHaveBeenCalled()
expect(firstRuntime.onPtyExit).not.toHaveBeenCalled()
diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts
index 5bcee8208bb..6d6b8ebff42 100644
--- a/src/main/ipc/terminal-startup-color-query-replies.ts
+++ b/src/main/ipc/terminal-startup-color-query-replies.ts
@@ -3,71 +3,10 @@ import { isTuiAgent } from '../../shared/tui-agent-config'
import { agentKindSchema } from '../../shared/telemetry-events'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import {
- parseTerminalOscColorQuery,
- terminalOscColorQueryReplies,
terminalOscColorQueryReply,
- type TerminalOscColorQueryReplyColors,
- type TerminalOscColorQuerySlot
+ type TerminalOscColorQueryReplyColors
} from '../../shared/terminal-osc-color-reply'
-type StartupTerminalColorQueryProvider = {
- write(id: string, data: string): void
-}
-
-type StartupTerminalColorQueryReplyState = {
- colors: TerminalOscColorQueryReplyColors
- pending: string
- answeredSlots: Set
- timeout: ReturnType
-}
-
-const STARTUP_TERMINAL_COLOR_QUERY_REPLY_WINDOW_MS = 5_000
-const STARTUP_TERMINAL_COLOR_QUERY_PENDING_CHARS = 64
-const startupTerminalColorQueryRepliesByPty = new Map()
-
-export function clearStartupTerminalColorQueryReplies(ptyId: string): void {
- const state = startupTerminalColorQueryRepliesByPty.get(ptyId)
- if (!state) {
- return
- }
- clearTimeout(state.timeout)
- startupTerminalColorQueryRepliesByPty.delete(ptyId)
-}
-
-export function moveStartupTerminalColorQueryReplies(fromPtyId: string, toPtyId: string): void {
- if (fromPtyId === toPtyId) {
- return
- }
- const state = startupTerminalColorQueryRepliesByPty.get(fromPtyId)
- if (!state) {
- return
- }
- startupTerminalColorQueryRepliesByPty.delete(fromPtyId)
- clearStartupTerminalColorQueryReplies(toPtyId)
- startupTerminalColorQueryRepliesByPty.set(toPtyId, state)
-}
-
-export function registerStartupTerminalColorQueryReplies(
- ptyId: string,
- colors: TerminalOscColorQueryReplyColors
-): void {
- if (!terminalOscColorQueryReply(colors, 10) || !terminalOscColorQueryReply(colors, 11)) {
- return
- }
- clearStartupTerminalColorQueryReplies(ptyId)
- const timeout = setTimeout(
- () => clearStartupTerminalColorQueryReplies(ptyId),
- STARTUP_TERMINAL_COLOR_QUERY_REPLY_WINDOW_MS
- )
- timeout.unref?.()
- startupTerminalColorQueryRepliesByPty.set(ptyId, {
- colors,
- pending: '',
- answeredSlots: new Set(),
- timeout
- })
-}
-
function normalizeTerminalColorQueryReplyColors(
value: unknown
): TerminalOscColorQueryReplyColors | null {
@@ -117,81 +56,3 @@ export function getStartupTerminalColorQueryReplyColors(args: {
}
return normalizeTerminalColorQueryReplyColors(args.terminalColorQueryReplies)
}
-
-function writeStartupTerminalColorQueryReplies(
- ptyId: string,
- slots: readonly TerminalOscColorQuerySlot[],
- state: StartupTerminalColorQueryReplyState,
- getProvider: (ptyId: string) => StartupTerminalColorQueryProvider | undefined
-): boolean {
- const replies = terminalOscColorQueryReplies(state.colors, slots)
- let provider: StartupTerminalColorQueryProvider | undefined
- try {
- provider = replies ? getProvider(ptyId) : undefined
- } catch {
- provider = undefined
- }
- if (!replies || !provider) {
- return false
- }
- try {
- for (const [index, reply] of replies.entries()) {
- const slot = slots[index]
- if (slot === undefined) {
- return false
- }
- provider.write(ptyId, reply)
- state.answeredSlots.add(slot)
- }
- return true
- } catch {
- return false
- }
-}
-
-export function answerStartupTerminalColorQueries(
- ptyId: string,
- data: string,
- getProvider: (ptyId: string) => StartupTerminalColorQueryProvider | undefined
-): string {
- const state = startupTerminalColorQueryRepliesByPty.get(ptyId)
- if (!state || data.length === 0) {
- return data
- }
- const input = state.pending + data
- let pending = ''
- let output = ''
- let offset = 0
- while (offset < input.length) {
- const candidateIndex = input.indexOf('\x1b', offset)
- if (candidateIndex === -1) {
- output += input.slice(offset)
- break
- }
- output += input.slice(offset, candidateIndex)
- const query = parseTerminalOscColorQuery(input, candidateIndex)
- if (query.kind === 'none') {
- output += input[candidateIndex]
- offset = candidateIndex + 1
- continue
- }
- if (query.kind === 'partial') {
- const candidate = input.slice(candidateIndex)
- if (candidate.length <= STARTUP_TERMINAL_COLOR_QUERY_PENDING_CHARS) {
- pending = candidate
- } else {
- output += candidate
- }
- break
- }
- if (!writeStartupTerminalColorQueryReplies(ptyId, query.slots, state, getProvider)) {
- output += input.slice(candidateIndex, query.endIndex)
- }
- offset = query.endIndex
- }
- state.pending = pending
- if (state.answeredSlots.has(10) && state.answeredSlots.has(11)) {
- clearStartupTerminalColorQueryReplies(ptyId)
- }
- return output
-}
diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts
index fcd6235b101..3eaa8c557d6 100644
--- a/src/main/providers/local-pty-provider.test.ts
+++ b/src/main/providers/local-pty-provider.test.ts
@@ -1582,6 +1582,47 @@ describe('LocalPtyProvider', () => {
expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' })
})
+ it('classifies startup queries before runtime and public data listeners', async () => {
+ const runtimeData = vi.fn()
+ const dataHandler = vi.fn()
+ provider.configure({ onData: runtimeData })
+ provider.onData(dataHandler)
+ const { id } = await provider.spawn({
+ cols: 80,
+ rows: 24,
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000,
+ echoProjection: 'windows-conpty-esc-stripped'
+ }
+ })
+ const onDataCb = mockProc.onData.mock.calls[0][0]
+ const query = '\x1b]10;?\x07'
+ const echo = ']10;rgb:2e2e/3434/3434\\'
+
+ onDataCb(query)
+ onDataCb(echo)
+ onDataCb('prompt')
+
+ expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
+ expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([
+ ['', expect.any(Number), query.length, true],
+ ['', expect.any(Number), echo.length, true],
+ ['prompt', expect.any(Number)]
+ ])
+ expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([
+ { id, data: '', sequenceChars: query.length, seq: query.length, transformed: true },
+ {
+ id,
+ data: '',
+ sequenceChars: echo.length,
+ seq: query.length + echo.length,
+ transformed: true
+ },
+ { id, data: 'prompt' }
+ ])
+ })
+
it('notifies exit listeners when PTY exits', async () => {
const exitHandler = vi.fn()
provider.onExit(exitHandler)
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index 28c5586152c..4cc456bd799 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -68,6 +68,7 @@ import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-defau
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
+import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -122,11 +123,18 @@ export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250
let loadGeneration = 0
const ptyLoadGeneration = new Map()
-type DataCallback = (payload: { id: string; data: string }) => void
+type DataCallback = (payload: {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+}) => void
type ExitCallback = (payload: { id: string; code: number }) => void
const dataListeners = new Set()
const exitListeners = new Set()
+const startupIngressByPty = new Map()
/**
* Returns a stable default cwd for locally spawned PTYs.
@@ -492,7 +500,13 @@ export type LocalPtyProviderOptions = {
pwshAvailable?: () => boolean
onSpawned?: (id: string) => void
onExit?: (id: string, code: number) => void
- onData?: (id: string, data: string, timestamp: number) => void
+ onData?: (
+ id: string,
+ data: string,
+ timestamp: number,
+ sequenceChars?: number,
+ transformed?: boolean
+ ) => void
}
export class LocalPtyProvider implements IPtyProvider {
@@ -907,6 +921,34 @@ export class LocalPtyProvider implements IPtyProvider {
ptyLoadGeneration.set(id, loadGeneration)
this.opts.onSpawned?.(id)
+ const emitIngressData = (emission: PtyIngressEmission): void => {
+ const sequenceChars = emission.rawEndSeq - emission.rawStartSeq
+ if (emission.transformed || sequenceChars !== emission.data.length) {
+ this.opts.onData?.(id, emission.data, Date.now(), sequenceChars, true)
+ } else {
+ this.opts.onData?.(id, emission.data, Date.now())
+ }
+ for (const cb of dataListeners) {
+ cb(
+ emission.transformed || sequenceChars !== emission.data.length
+ ? {
+ id,
+ data: emission.data,
+ sequenceChars,
+ seq: emission.rawEndSeq,
+ transformed: true
+ }
+ : { id, data: emission.data }
+ )
+ }
+ }
+ const startupIngress = new PtyStartupIngress({
+ ...(args.startupIngress ? { intent: args.startupIngress } : {}),
+ write: (data) => proc.write(data),
+ onEmission: emitIngressData
+ })
+ startupIngressByPty.set(id, startupIngress)
+
// Shell-ready startup command support
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
let shellReadyTimeout: ReturnType | null = null
@@ -938,10 +980,7 @@ export class LocalPtyProvider implements IPtyProvider {
if (heldBytes.length === 0) {
return
}
- this.opts.onData?.(id, heldBytes, Date.now())
- for (const cb of dataListeners) {
- cb({ id, data: heldBytes })
- }
+ startupIngress.accept(heldBytes)
}
if (args.command) {
if (shellReadyLaunch?.supportsReadyMarker) {
@@ -977,13 +1016,7 @@ export class LocalPtyProvider implements IPtyProvider {
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
}
}
- if (data.length === 0) {
- return
- }
- this.opts.onData?.(id, data, Date.now())
- for (const cb of dataListeners) {
- cb({ id, data })
- }
+ startupIngress.accept(data)
})
if (onDataDisposable) {
disposables.push(onDataDisposable)
@@ -1010,6 +1043,8 @@ export class LocalPtyProvider implements IPtyProvider {
}
startupCommandCleanup?.()
clearPtyState(id)
+ startupIngress.drainAndClose()
+ startupIngressByPty.delete(id)
// Why: release the master ptmx fd on the natural-exit path — without
// this, a shell that exits cleanly (the common case) never releases its
// fd until the next GC. See docs/fix-pty-fd-leak.md.
@@ -1223,11 +1258,15 @@ export class LocalPtyProvider implements IPtyProvider {
// only safe at an empty prompt, and without a headless emulator this
// provider cannot tell whether input is pending.
try {
+ startupIngressByPty.get(id)?.snapshotBarrier()
ptyProcesses.get(id)?.clear()
} catch {
/* PTY may have just exited */
}
}
+ closeStartupQueryAuthority(id: string): number {
+ return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0
+ }
acknowledgeDataEvent(_id: string, _charCount: number): void {
/* no flow control for local */
}
diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts
new file mode 100644
index 00000000000..6de435cb23f
--- /dev/null
+++ b/src/main/providers/pty-provider-events.ts
@@ -0,0 +1,22 @@
+import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
+
+export type PtyDataEvent = {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+}
+
+/** Notification-bearing fact a thinning transport detected while it held
+ * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */
+export type PtyTransientFact =
+ | { kind: 'bell' }
+ | { kind: 'command-finished'; exitCode: number | null }
+ | { kind: 'pr-link'; link: TerminalGitHubPRLink }
+ | { kind: '2031-subscribe' }
+
+export type PtyBackgroundStreamEvent =
+ | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string }
+ | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number }
+ | { id: string; kind: 'transientFact'; fact: PtyTransientFact }
diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts
index 375aeb86b54..ccbc60edcee 100644
--- a/src/main/providers/ssh-pty-provider.test.ts
+++ b/src/main/providers/ssh-pty-provider.test.ts
@@ -49,6 +49,21 @@ describe('SshPtyProvider', () => {
expect(result).toEqual({ id: scopedPty1 })
})
+ it('gates fresh startup intent with the relay ingress capability version', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-1' })
+ const startupIngress = {
+ colors: { foreground: '#eeeeee', background: '#111111' },
+ deadlineMs: 5_000
+ }
+
+ await provider.spawn({ cols: 80, rows: 24, startupIngress })
+
+ expect(mux.request).toHaveBeenCalledWith(
+ 'pty.spawn',
+ expect.objectContaining({ startupIngressVersion: 1, startupIngress })
+ )
+ })
+
it('passes cwd and env through', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
@@ -324,6 +339,26 @@ describe('SshPtyProvider', () => {
})
})
+ it('never sends fresh startup intent on relay reattach', async () => {
+ mux.request.mockResolvedValue({ replay: 'restored' })
+
+ await provider.spawn({
+ cols: 80,
+ rows: 24,
+ sessionId: scopedPty1,
+ startupIngress: {
+ colors: { foreground: '#eeeeee', background: '#111111' },
+ deadlineMs: 5_000
+ }
+ })
+
+ expect(mux.request).toHaveBeenCalledWith(
+ 'pty.attach',
+ expect.not.objectContaining({ startupIngress: expect.anything() })
+ )
+ expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
+ })
+
it('reattaches scoped app ids using raw relay ids', async () => {
mux.request.mockResolvedValue({ replay: 'buffered-output' })
@@ -531,6 +566,28 @@ describe('SshPtyProvider', () => {
expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'output' })
})
+ it('forwards empty transformed relay spans without reinterpreting them', () => {
+ const handler = vi.fn()
+ provider.onData(handler)
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+
+ notifHandler('pty.data', {
+ id: 'pty-1',
+ data: '',
+ rawLength: 9,
+ seq: 9,
+ transformed: true
+ })
+
+ expect(handler).toHaveBeenCalledWith({
+ id: scopedPty1,
+ data: '',
+ sequenceChars: 9,
+ seq: 9,
+ transformed: true
+ })
+ })
+
it('forwards pty.replay notifications to replay listeners', () => {
const handler = vi.fn()
provider.onReplay(handler)
diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts
index bdc81787996..1182104cefa 100644
--- a/src/main/providers/ssh-pty-provider.ts
+++ b/src/main/providers/ssh-pty-provider.ts
@@ -2,8 +2,15 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
+import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
-type DataCallback = (payload: { id: string; data: string }) => void
+type DataCallback = (payload: {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ seq?: number
+}) => void
type ReplayCallback = (payload: { id: string; data: string }) => void
type ExitCallback = (payload: { id: string; code: number }) => void
type RemoteCliBridgeEnv = {
@@ -56,7 +63,15 @@ export class SshPtyProvider implements IPtyProvider {
switch (method) {
case 'pty.data':
for (const cb of this.dataListeners) {
- cb({ id: this.toAppPtyId(params.id as string), data: params.data as string })
+ cb({
+ id: this.toAppPtyId(params.id as string),
+ data: params.data as string,
+ ...(typeof params.rawLength === 'number'
+ ? { sequenceChars: params.rawLength as number }
+ : {}),
+ ...(params.transformed === true ? { transformed: true } : {}),
+ ...(typeof params.seq === 'number' ? { seq: params.seq as number } : {})
+ })
}
break
@@ -167,7 +182,13 @@ export class SshPtyProvider implements IPtyProvider {
// remote hooks are disabled, but the relay still needs attach identity
// metadata to reject cross-generation PTY id collisions.
...(opts.paneKey ? { paneKey: opts.paneKey } : {}),
- ...(opts.tabId ? { tabId: opts.tabId } : {})
+ ...(opts.tabId ? { tabId: opts.tabId } : {}),
+ ...(opts.startupIngress
+ ? {
+ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
+ startupIngress: opts.startupIngress
+ }
+ : {})
})
return {
...(result as PtySpawnResult),
@@ -261,6 +282,13 @@ export class SshPtyProvider implements IPtyProvider {
await this.mux.request('pty.clearBuffer', { id: this.toRelayPtyId(id) })
}
+ async closeStartupQueryAuthority(id: string): Promise {
+ const result = (await this.mux.request('pty.closeStartupQueryAuthority', {
+ id: this.toRelayPtyId(id)
+ })) as { appliedSeq?: number }
+ return result.appliedSeq ?? 0
+ }
+
acknowledgeDataEvent(id: string, charCount: number): void {
this.mux.notify('pty.ackData', { id: this.toRelayPtyId(id), charCount })
}
diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts
index 6941e4c3063..ea2ea2c57dc 100644
--- a/src/main/providers/types.ts
+++ b/src/main/providers/types.ts
@@ -18,29 +18,23 @@ import type {
SearchResult
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
+import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
-import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import type { GitProviderStatusOptions } from './git-provider-status-options'
+import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events'
import type { PtySpawnResult } from './pty-spawn-result'
+export type {
+ PtyBackgroundStreamEvent,
+ PtyDataEvent,
+ PtyTransientFact
+} from './pty-provider-events'
+
// ─── PTY Provider ───────────────────────────────────────────────────
-/** Notification-bearing fact a thinning transport detected while it held
- * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */
-export type PtyTransientFact =
- | { kind: 'bell' }
- | { kind: 'command-finished'; exitCode: number | null }
- | { kind: 'pr-link'; link: TerminalGitHubPRLink }
- | { kind: '2031-subscribe' }
-
-export type PtyBackgroundStreamEvent =
- | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string }
- | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number }
- | { id: string; kind: 'transientFact'; fact: PtyTransientFact }
-
export type PtyProviderBufferSnapshot = {
data: string
/** Authoritative normal buffer captured beside an alternate-screen frame. */
@@ -98,6 +92,8 @@ export type PtySpawnOptions = {
* through spawn options keeps local PTY and daemon PTY semantics aligned
* without promoting pwsh into a separate shell family. */
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
+ /** Fresh-spawn-only source authority installed before any PTY output is released. */
+ startupIngress?: PtyStartupIngressIntent
}
export type { PtySpawnResult }
@@ -170,6 +166,8 @@ export type IPtyProvider = {
getCwd(id: string): Promise
getInitialCwd(id: string): Promise
clearBuffer(id: string): Promise
+ /** Ordered handoff from startup source authority to the live/hidden view authority. */
+ closeStartupQueryAuthority?: (id: string) => Promise | number
acknowledgeDataEvent(id: string, charCount: number): void
hasChildProcesses(id: string): Promise
getForegroundProcess(id: string): Promise
@@ -180,9 +178,7 @@ export type IPtyProvider = {
listProcesses(): Promise
getDefaultShell(): Promise
getProfiles(): Promise<{ name: string; path: string }[]>
- onData(
- callback: (payload: { id: string; data: string; sequenceChars?: number }) => void
- ): () => void
+ onData(callback: (payload: PtyDataEvent) => void): () => void
onReplay(callback: (payload: { id: string; data: string }) => void): () => void
onExit(callback: (payload: { id: string; code: number }) => void): () => void
}
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 9231beb040a..b5e2f0df995 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -2282,7 +2282,12 @@ export class OrcaRuntimeService {
// without polling. Keyed by ptyId for O(1) lookup per data event.
private dataListeners = new Map<
string,
- Set<(data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void>
+ Set<
+ (
+ data: string,
+ meta?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string }
+ ) => void
+ >
>()
// Why: startup draft paste can subscribe after the agent already emitted its
// ready marker. Keep a bounded raw buffer so fast startup output is replayed.
@@ -6064,7 +6069,13 @@ export class OrcaRuntimeService {
* Handles incoming data from a PTY process, running agent detection,
* updating terminal tail buffers, and triggering foreground agent refreshes.
*/
- onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number {
+ onPtyData(
+ ptyId: string,
+ data: string,
+ at: number,
+ sequenceChars = data.length,
+ transformed = false
+ ): number {
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
this.ptyOutputSequenceById.set(ptyId, outputSequence)
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
@@ -6282,7 +6293,8 @@ export class OrcaRuntimeService {
if (listeners) {
const meta = {
seq: outputSequence,
- rawLength: data.length,
+ rawLength: sequenceChars,
+ ...(transformed ? { transformed: true } : {}),
...(cwdChanged && cwd !== null ? { cwd } : {})
}
for (const listener of listeners) {
@@ -7133,7 +7145,10 @@ export class OrcaRuntimeService {
subscribeToTerminalData(
ptyId: string,
- listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void
+ listener: (
+ data: string,
+ meta?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string }
+ ) => void
): () => void {
return addListenerToMap(this.dataListeners, ptyId, listener)
}
diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts
index b0983d5e06f..05c5045c50b 100644
--- a/src/main/runtime/rpc/methods/terminal.ts
+++ b/src/main/runtime/rpc/methods/terminal.ts
@@ -140,11 +140,17 @@ type TerminalOutputChunk = {
meta?: TerminalOutputMeta
}
-type TerminalOutputMeta = { seq?: number; rawLength?: number; cwd?: string }
+type TerminalOutputMeta = {
+ seq?: number
+ rawLength?: number
+ transformed?: boolean
+ cwd?: string
+}
type TerminalOutputFrameChunk = {
bytes: Uint8Array
seq?: number
+ opcode?: TerminalStreamOpcode
}
function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): {
@@ -156,6 +162,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
let bytes = 0
let lastSeq: number | undefined
let pendingCwd: string | undefined
+ let pendingRawLength = 0
let timer: ReturnType | null = null
const clearTimer = (): void => {
@@ -168,14 +175,14 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
const flush = (): void => {
clearTimer()
- if (chunks.length === 0) {
+ if (chunks.length === 0 && pendingRawLength === 0) {
return
}
const data = chunks.length === 1 ? chunks[0]! : chunks.join('')
const meta =
typeof lastSeq === 'number' || pendingCwd !== undefined
? {
- ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : {}),
+ ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}),
...(pendingCwd !== undefined ? { cwd: pendingCwd } : {})
}
: undefined
@@ -183,12 +190,19 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
bytes = 0
lastSeq = undefined
pendingCwd = undefined
+ pendingRawLength = 0
onFlush(data, meta)
}
return {
push(data: string, meta?: TerminalOutputMeta): void {
- if (!data) {
+ const rawLength = meta?.rawLength ?? data.length
+ if (!data && rawLength === 0) {
+ return
+ }
+ if (meta?.transformed || rawLength !== data.length) {
+ flush()
+ onFlush(data, { ...meta, rawLength, transformed: true })
return
}
if (meta?.cwd !== undefined) {
@@ -196,6 +210,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
pendingCwd = meta.cwd
}
chunks.push(data)
+ pendingRawLength += rawLength
const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
@@ -222,6 +237,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
clearTimer()
chunks = []
bytes = 0
+ pendingRawLength = 0
}
}
}
@@ -230,11 +246,19 @@ function* iterateTerminalOutputFrameChunks(
data: string,
meta?: TerminalOutputMeta
): Generator {
+ const rawLength = meta?.rawLength ?? data.length
+ if (meta?.transformed || rawLength !== data.length) {
+ yield {
+ opcode: TerminalStreamOpcode.OutputSpan,
+ bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
+ seq: meta?.seq
+ }
+ return
+ }
if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
- const rawLength = meta?.rawLength ?? data.length
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
@@ -1640,7 +1664,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void => {
- sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
+ sendFrame(
+ stream.streamId,
+ chunk.opcode ?? TerminalStreamOpcode.Output,
+ chunk.bytes,
+ chunk.seq
+ )
if (stream.ackOutput) {
stream.ackInFlightBytes += chunk.bytes.byteLength
ackTotalInFlightBytes += chunk.bytes.byteLength
@@ -2802,7 +2831,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
)
}
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
- sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
+ sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
}
})
unregisterBinaryHandler =
@@ -3199,6 +3228,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!initialOutputOverflowed) {
for (const item of bufferedOutput) {
let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
+ let uncoveredMeta = item.meta
if (
uncoveredData &&
uncoveredData !== item.data &&
@@ -3206,6 +3236,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
typeof item.meta?.seq === 'number' &&
typeof item.meta.rawLength === 'number'
) {
+ if (item.meta.rawLength === item.data.length) {
+ uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length }
+ }
uncoveredData = stripSnapshotBoundaryQuerySuffixes(
uncoveredData,
snapshotOutputSeq,
@@ -3214,7 +3247,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
)
}
if (uncoveredData) {
- outputBatcher.push(uncoveredData, item.meta)
+ outputBatcher.push(uncoveredData, uncoveredMeta)
}
}
}
diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts
new file mode 100644
index 00000000000..901f7940d31
--- /dev/null
+++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { SshRelaySession } from './ssh-relay-session'
+import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
+
+const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
+
+vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
+
+vi.mock('./ssh-channel-multiplexer', () => ({
+ SshChannelMultiplexer: class MockSshChannelMultiplexer {
+ notify = vi.fn()
+ request = muxRequestMock
+ onNotification = vi.fn().mockReturnValue(() => {})
+ onRequest = vi.fn().mockReturnValue(() => {})
+ onDispose = vi.fn().mockReturnValue(() => {})
+ dispose = vi.fn()
+ isDisposed = vi.fn().mockReturnValue(false)
+ }
+}))
+
+vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
+ installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([])
+}))
+
+vi.mock('../providers/ssh-pty-provider', () => ({
+ isSshPtyNotFoundError: vi.fn().mockReturnValue(false),
+ isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false),
+ SshPtyProvider: class MockSshPtyProvider {
+ onData = vi.fn().mockReturnValue(() => {})
+ onReplay = vi.fn().mockReturnValue(() => {})
+ onExit = vi.fn().mockReturnValue(() => {})
+ attach = vi.fn().mockResolvedValue(undefined)
+ attachForReconnect = vi.fn().mockResolvedValue({})
+ dispose = vi.fn()
+ }
+}))
+
+vi.mock('../providers/ssh-filesystem-provider', () => ({
+ SshFilesystemProvider: class MockSshFilesystemProvider {
+ dispose = vi.fn()
+ }
+}))
+
+vi.mock('../providers/ssh-git-provider', () => ({
+ SshGitProvider: class MockSshGitProvider {}
+}))
+
+vi.mock('../ipc/pty', () => ({
+ registerSshPtyProvider: vi.fn(),
+ unregisterSshPtyProvider: vi.fn(),
+ getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }),
+ getPtyIdsForConnection: vi.fn().mockReturnValue([]),
+ clearPtyOwnershipForConnection: vi.fn(),
+ clearProviderPtyState: vi.fn(),
+ deletePtyOwnership: vi.fn(),
+ setPtyOwnership: vi.fn()
+}))
+
+vi.mock('../providers/ssh-filesystem-dispatch', () => ({
+ registerSshFilesystemProvider: vi.fn(),
+ unregisterSshFilesystemProvider: vi.fn(),
+ getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
+}))
+
+vi.mock('../providers/ssh-git-dispatch', () => ({
+ registerSshGitProvider: vi.fn(),
+ unregisterSshGitProvider: vi.fn()
+}))
+
+const { registerSshPtyProvider } = await import('../ipc/pty')
+
+describe('SshRelaySession data delivery', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ muxRequestMock.mockResolvedValue([])
+ mockDeploySuccess()
+ })
+
+ it('delivers empty transformed relay spans with raw sequence metadata', async () => {
+ const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
+ const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() }
+ const session = new SshRelaySession(
+ 'target-1',
+ getMainWindow,
+ mockStore,
+ mockPortForward,
+ runtime as never
+ )
+ await session.establish(mockConn)
+ const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
+ onData: ReturnType
+ }
+ const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
+ id: string
+ data: string
+ sequenceChars?: number
+ transformed?: boolean
+ }) => void
+
+ onData({ id: 'ssh-pty-1', data: '', sequenceChars: 9, transformed: true })
+
+ expect(runtime.onPtyData).toHaveBeenCalledWith('ssh-pty-1', '', expect.any(Number), 9, true)
+ expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
+ id: 'ssh-pty-1',
+ data: '',
+ sequenceChars: 9,
+ transformed: true,
+ seq: 17,
+ rawLength: 9
+ })
+ })
+})
diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts
index 56d12ec9cef..ce9317c6660 100644
--- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts
+++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts
@@ -65,8 +65,7 @@ vi.mock('../ipc/pty', () => ({
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
- setPtyOwnership: vi.fn(),
- answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data)
+ setPtyOwnership: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts
index da490ae7be0..55abd17b9c6 100644
--- a/src/main/ssh/ssh-relay-session.test.ts
+++ b/src/main/ssh/ssh-relay-session.test.ts
@@ -73,8 +73,7 @@ vi.mock('../ipc/pty', () => ({
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
- setPtyOwnership: vi.fn(),
- answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data)
+ setPtyOwnership: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
@@ -152,7 +151,9 @@ describe('SshRelaySession', () => {
expect(runtime.onPtyData).toHaveBeenCalledWith(
'ssh-pty-1',
'hidden ssh output',
- expect.any(Number)
+ expect.any(Number),
+ 'hidden ssh output'.length,
+ undefined
)
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why out-of-band: an in-band empty pty:data sentinel is ambiguous with
@@ -207,7 +208,8 @@ describe('SshRelaySession', () => {
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: 'ssh-pty-1',
- data: 'still delivered'
+ data: 'still delivered',
+ rawLength: 'still delivered'.length
})
})
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 969878c1220..b9c41c84c1d 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -41,8 +41,7 @@ import {
clearPtyOwnershipForConnection,
clearProviderPtyState,
deletePtyOwnership,
- setPtyOwnership,
- answerStartupTerminalColorQueriesForPty
+ setPtyOwnership
} from '../ipc/pty'
import {
recordHiddenRendererPtyDataDrop,
@@ -1120,8 +1119,14 @@ export class SshRelaySession {
private wireUpPtyEvents(ptyProvider: SshPtyProvider): void {
ptyProvider.onData((payload) => {
- const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now())
- const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data)
+ const rawLength = payload.sequenceChars ?? payload.data.length
+ const seq = this.runtime?.onPtyData(
+ payload.id,
+ payload.data,
+ Date.now(),
+ rawLength,
+ payload.transformed
+ )
const win = this.getMainWindow()
if (!win || win.isDestroyed()) {
return
@@ -1133,7 +1138,7 @@ export class SshRelaySession {
// OSC-9999-only chunks legitimately strip to empty in the renderer.
const store = this.store as { getSettings?: Store['getSettings'] }
if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) {
- const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
+ const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength)
if (drop.shouldEmitRestoreMarker) {
win.webContents.send('pty:modelRestoreNeeded', {
id: payload.id,
@@ -1143,16 +1148,12 @@ export class SshRelaySession {
}
return
}
- // Why: startup color-query answering can strip query-only chunks to
- // empty; skip empty sends and only attach seq metadata when the chunk
- // reaches the renderer unmodified (seq tracks raw stream offsets).
- if (rendererData.length > 0) {
+ if (payload.data.length > 0 || payload.transformed) {
win.webContents.send('pty:data', {
...payload,
- data: rendererData,
- ...(rendererData === payload.data && typeof seq === 'number'
- ? { seq, rawLength: payload.data.length }
- : {})
+ ...(typeof seq === 'number' ? { seq } : {}),
+ rawLength,
+ ...(payload.transformed ? { transformed: true } : {})
})
}
})
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 3911de36f9d..487f7b2e0dd 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1439,6 +1439,7 @@ export type PreloadApi = {
data: string
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
droppedOutput?: boolean
}) => void
diff --git a/src/preload/index.ts b/src/preload/index.ts
index dc8023b1e85..5571f9349b9 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1061,6 +1061,7 @@ const api = {
data: string
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
droppedOutput?: boolean
}) => void
@@ -1072,6 +1073,7 @@ const api = {
data: string
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
droppedOutput?: boolean
}
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index 8de7ec8051b..0c0b33d9b94 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -10,6 +10,7 @@ import {
resolveSetupAgentSequenceLaunchCommand,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
+import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -968,6 +969,72 @@ describe('PtyHandler', () => {
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello world' })
})
+ it('consumes capable startup queries before relay replay and fanout', async () => {
+ let dataCallback: ((data: string) => void) | undefined
+ const term = {
+ ...mockPtyInstance,
+ onData: vi.fn((cb: (data: string) => void) => {
+ dataCallback = cb
+ }),
+ onExit: vi.fn()
+ }
+ mockPtySpawn.mockReturnValue(term)
+ await dispatcher.callRequest('pty.spawn', {
+ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000
+ }
+ })
+
+ const query = '\x1b]10;?\x07'
+ dataCallback!(query)
+ dataCallback!('prompt')
+ vi.advanceTimersByTime(8)
+
+ expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
+ id: 'pty-1',
+ data: '',
+ rawLength: query.length,
+ seq: query.length,
+ transformed: true
+ })
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'prompt' })
+ await expect(
+ dispatcher.callRequest('pty.attach', {
+ id: 'pty-1',
+ suppressReplayNotification: true
+ })
+ ).resolves.toEqual({ replay: 'prompt' })
+ })
+
+ it('leaves startup queries untouched for an unsupported relay capability version', async () => {
+ let dataCallback: ((data: string) => void) | undefined
+ const term = {
+ ...mockPtyInstance,
+ onData: vi.fn((cb: (data: string) => void) => {
+ dataCallback = cb
+ }),
+ onExit: vi.fn()
+ }
+ mockPtySpawn.mockReturnValue(term)
+ await dispatcher.callRequest('pty.spawn', {
+ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1,
+ startupIngress: {
+ colors: { foreground: '#2e3434', background: '#ffffff' },
+ deadlineMs: 5_000
+ }
+ })
+
+ const query = '\x1b]10;?\x07'
+ dataCallback!(query)
+ vi.advanceTimersByTime(8)
+
+ expect(term.write).not.toHaveBeenCalled()
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query })
+ })
+
it('coalesces background PTY output before notifying the client', async () => {
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index fb0d38bf53c..d06695342d0 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -39,6 +39,12 @@ import {
} from '../shared/git-credential-prompt-env'
import { isTuiAgent } from '../shared/tui-agent-config'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
+import {
+ PTY_STARTUP_INGRESS_VERSION,
+ PtyStartupIngress,
+ parsePtyStartupIngressIntent,
+ type PtyIngressEmission
+} from '../shared/pty-startup-ingress'
function isMissingNodePtyNativeBinding(error: unknown): boolean {
return (
@@ -78,10 +84,15 @@ type ManagedPty = {
physicalExit?: PhysicalExitTracker
forceKillSent?: boolean
gracefulKillSent?: boolean
+ startupIngress?: PtyStartupIngress
+ startupIngressIntent?: ReturnType
}
type PendingPtyOutput = {
data: string
+ rawLength?: number
+ transformed?: boolean
+ seq?: number
}
type ManagedStartupCommand = {
@@ -471,6 +482,9 @@ export class PtyHandler {
}
private appendReplayBuffer(managed: ManagedPty, data: string): void {
+ if (data.length === 0) {
+ return
+ }
managed.buffered += data
if (managed.buffered.length > REPLAY_BUFFER_MAX) {
managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
@@ -504,8 +518,7 @@ export class PtyHandler {
if (startup.scanState) {
const heldBytes = drainShellReadyHeldBytes(startup.scanState)
if (heldBytes) {
- this.appendReplayBuffer(managed, heldBytes)
- this.enqueuePtyOutput(managed.id, heldBytes)
+ managed.startupIngress?.accept(heldBytes)
}
}
const submit = process.platform === 'win32' ? '\r' : '\n'
@@ -525,6 +538,22 @@ export class PtyHandler {
private wireAndStore(managed: ManagedPty): void {
managed.physicalExit = new PhysicalExitTracker()
this.ptys.set(managed.id, managed)
+ const emitIngressData = (emission: PtyIngressEmission): void => {
+ const rawLength = emission.rawEndSeq - emission.rawStartSeq
+ this.appendReplayBuffer(managed, emission.data)
+ this.enqueuePtyOutput(
+ managed.id,
+ emission.data,
+ emission.transformed || rawLength !== emission.data.length
+ ? { rawLength, seq: emission.rawEndSeq, transformed: true }
+ : {}
+ )
+ }
+ managed.startupIngress ??= new PtyStartupIngress({
+ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}),
+ write: (data) => managed.pty.write(data),
+ onEmission: emitIngressData
+ })
managed.pty.onData((data: string) => {
const startup = managed.startupCommand
if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
@@ -534,8 +563,7 @@ export class PtyHandler {
this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
}
}
- this.appendReplayBuffer(managed, data)
- this.enqueuePtyOutput(managed.id, data)
+ managed.startupIngress?.accept(data)
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
managed.physicalExit?.markExited()
@@ -560,6 +588,7 @@ export class PtyHandler {
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
+ this.releaseRelayIngress(managed)
this.flushPtyOutput(managed.id)
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
this.notifyExitListener(managed)
@@ -572,6 +601,17 @@ export class PtyHandler {
})
}
+ private releaseRelayIngress(managed: ManagedPty): void {
+ const startupCommand = managed.startupCommand
+ const scanState = startupCommand?.scanState
+ if (scanState) {
+ const held = drainShellReadyHeldBytes(scanState)
+ startupCommand.scanState = null
+ managed.startupIngress?.accept(held)
+ }
+ managed.startupIngress?.drainAndClose()
+ }
+
private notifyExitListener(managed: ManagedPty): void {
if (managed.exitListenerNotified) {
return
@@ -605,6 +645,9 @@ export class PtyHandler {
this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p))
this.dispatcher.onRequest('pty.revive', (p) => this.revive(p))
this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles())
+ this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) =>
+ this.closeStartupQueryAuthority(p)
+ )
this.dispatcher.onNotification('pty.data', (p) => this.writeData(p))
this.dispatcher.onNotification('pty.resize', (p) => this.resize(p))
@@ -620,6 +663,17 @@ export class PtyHandler {
return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[')
}
+ private async closeStartupQueryAuthority(
+ params: Record
+ ): Promise<{ appliedSeq: number }> {
+ const id = params.id as string
+ const managed = this.ptys.get(id)
+ if (!managed || managed.disposed) {
+ throw new Error(`PTY "${id}" not found`)
+ }
+ return { appliedSeq: managed.startupIngress?.closeQueryAuthority() ?? 0 }
+ }
+
private shouldSendInteractiveOutputNow(id: string, data: string): boolean {
const lastInputAt = this.lastInputAtByPty.get(id)
const now = performance.now()
@@ -640,15 +694,35 @@ export class PtyHandler {
return true
}
- private enqueuePtyOutput(id: string, data: string): void {
+ private enqueuePtyOutput(
+ id: string,
+ data: string,
+ meta: { rawLength?: number; transformed?: boolean; seq?: number } = {}
+ ): void {
const existing = this.pendingOutputByPty.get(id)
- const pending = { data: (existing?.data ?? '') + data }
+ if (meta.transformed === true) {
+ // Why: transformed spans have no raw-to-clean slice mapping, so neither
+ // side of their boundary may be folded into the relay's output batch.
+ if (existing) {
+ this.flushPtyOutput(id)
+ }
+ this.dispatcher.notify('pty.data', { id, data, ...meta })
+ return
+ }
+ const pending: PendingPtyOutput = { data: (existing?.data ?? '') + data }
+ if (existing?.rawLength !== undefined || meta.rawLength !== undefined) {
+ pending.rawLength =
+ (existing?.rawLength ?? existing?.data.length ?? 0) + (meta.rawLength ?? data.length)
+ }
+ if (meta.seq !== undefined) {
+ pending.seq = meta.seq
+ }
if (this.shouldSendInteractiveOutputNow(id, pending.data)) {
this.pendingOutputByPty.delete(id)
this.clearOutputFlushTimerIfIdle()
// Why: remote agent TUIs redraw around each keystroke. Background relay
// batching should reduce SSH chatter, not add visible input echo delay.
- this.dispatcher.notify('pty.data', { id, data: pending.data })
+ this.dispatcher.notify('pty.data', { id, ...pending })
return
}
this.pendingOutputByPty.set(id, pending)
@@ -670,12 +744,31 @@ export class PtyHandler {
break
}
this.pendingOutputByPty.delete(id)
- const chunk = pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS)
- const remaining = pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS)
+ const chunk = pending.transformed
+ ? pending.data
+ : pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS)
+ const remaining = pending.transformed ? '' : pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS)
if (remaining) {
- this.pendingOutputByPty.set(id, { data: remaining })
+ this.pendingOutputByPty.set(id, {
+ data: remaining,
+ ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }),
+ seq: pending.seq
+ })
}
- this.dispatcher.notify('pty.data', { id, data: chunk })
+ const chunkRawLength = pending.transformed
+ ? pending.rawLength
+ : pending.rawLength === undefined
+ ? undefined
+ : chunk.length
+ const chunkSeq =
+ pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length)
+ this.dispatcher.notify('pty.data', {
+ id,
+ data: chunk,
+ ...(chunkSeq === undefined ? {} : { seq: chunkSeq }),
+ ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }),
+ ...(pending.transformed ? { transformed: true } : {})
+ })
writes++
}
if (this.pendingOutputByPty.size > 0 && writes > 0) {
@@ -691,7 +784,7 @@ export class PtyHandler {
return
}
this.pendingOutputByPty.delete(id)
- this.dispatcher.notify('pty.data', { id, data: pending.data })
+ this.dispatcher.notify('pty.data', { id, ...pending })
this.clearOutputFlushTimerIfIdle()
}
@@ -891,6 +984,12 @@ export class PtyHandler {
tabId: typeof params.tabId === 'string' ? params.tabId : tabId
}
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
+ const startupIngressIntent =
+ params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION
+ ? parsePtyStartupIngressIntent(params.startupIngress, {
+ allowWindowsEchoProjection: false
+ })
+ : undefined
const managed: ManagedPty = {
id,
pty: term,
@@ -903,6 +1002,7 @@ export class PtyHandler {
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
+ ...(startupIngressIntent ? { startupIngressIntent } : {}),
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
? {
@@ -958,6 +1058,8 @@ export class PtyHandler {
// grace window already takes.
if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
managed.physicalExit?.markExited()
+ this.releaseRelayIngress(managed)
+ this.flushPtyOutput(id)
this.notifyExitListener(managed)
disposeManagedPty(managed)
this.ptys.delete(id)
@@ -984,6 +1086,8 @@ export class PtyHandler {
throw new Error(`PTY "${id}" not found (identity mismatch)`)
}
+ managed.startupIngress?.snapshotBarrier()
+
// Replay buffered output. During pty.spawn({ sessionId }) the renderer has
// not registered replay handlers yet, so return the bytes to the caller
// instead of notifying them too early.
@@ -1175,6 +1279,7 @@ export class PtyHandler {
const id = params.id as string
const managed = this.ptys.get(id)
if (managed && !managed.disposed) {
+ managed.startupIngress?.snapshotBarrier()
managed.pty.clear()
}
}
@@ -1385,6 +1490,10 @@ export class PtyHandler {
private async disposePtys(waitForPhysicalExit: boolean): Promise {
this.cancelGraceTimer()
await this.waitForPendingPtyCreations()
+ for (const managed of this.ptys.values()) {
+ this.releaseRelayIngress(managed)
+ this.flushPtyOutput(managed.id)
+ }
if (this.outputFlushTimer !== null) {
clearTimeout(this.outputFlushTimer)
this.outputFlushTimer = null
@@ -1414,6 +1523,7 @@ export class PtyHandler {
managed.killTimer = undefined
}
this.clearStartupCommandTimer(managed)
+ this.releaseRelayIngress(managed)
// Why: relay exit must retain the native owner until SIGKILL is accepted
// (with one bounded retry) or onExit proves the process is already gone.
await this.requestForceKillForRelayShutdown(managed)
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 18066ae75d2..0c1259d665e 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -7085,6 +7085,7 @@ export function connectPanePty(
return
}
if (!foreground && orderedRendererData.length === 0) {
+ recordRendererOrderedSeq(rendererMeta)
schedulePendingStartupCommandDelivery()
return
}
diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts
index 2b8781a5221..efc103531ba 100644
--- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts
+++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts
@@ -37,6 +37,7 @@ import { installTerminalFreezeReport } from './terminal-freeze-report'
export type PtyDataMeta = {
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
/** Main dropped this PTY's buffered output at the pending cap; the pane
* must repaint from the main-owned snapshot instead of the live stream. */
@@ -170,6 +171,7 @@ function handleDispatchedPtyData(payload: {
data: string
seq?: number
rawLength?: number
+ transformed?: boolean
background?: boolean
droppedOutput?: boolean
}): void {
@@ -182,6 +184,10 @@ function handleDispatchedPtyData(payload: {
meta ??= {}
meta.rawLength = payload.rawLength
}
+ if (payload.transformed === true) {
+ meta ??= {}
+ meta.transformed = true
+ }
if (payload.background === true) {
meta ??= {}
meta.background = true
diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts
index e5ada3e6ca6..0c49f0a479f 100644
--- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts
+++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts
@@ -105,6 +105,23 @@ class FakeMultiplexServer {
this.send(TerminalStreamOpcode.Output, encodeTerminalStreamText(text), this.cursorUnits)
}
+ outputSpan(data: string, rawLength: number): void {
+ this.cursorUnits += rawLength
+ this.send(
+ TerminalStreamOpcode.OutputSpan,
+ encodeTerminalStreamJson({ data, rawLength, transformed: true }),
+ this.cursorUnits
+ )
+ }
+
+ malformedOutputSpan(): void {
+ this.send(
+ TerminalStreamOpcode.OutputSpan,
+ encodeTerminalStreamJson({ data: 'framing must not render' }),
+ this.cursorUnits
+ )
+ }
+
flushHeldManualSnapshot(): void {
if (this.heldManualRequestId === null) {
throw new Error('No manual snapshot is held')
@@ -144,24 +161,29 @@ describe('remote terminal frame-drop resync', () => {
async function subscribeClient(): Promise<{
data: string[]
+ metas: { seq?: number; rawLength?: number; transformed?: boolean }[]
snapshots: string[]
stream: RemoteRuntimeMultiplexedTerminal
}> {
const data: string[] = []
+ const metas: { seq?: number; rawLength?: number; transformed?: boolean }[] = []
const snapshots: string[] = []
const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-1')
const stream = await multiplexer.subscribeTerminal({
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
callbacks: {
- onData: (chunk) => data.push(chunk),
+ onData: (chunk, meta) => {
+ data.push(chunk)
+ metas.push(meta ?? {})
+ },
onSnapshot: (chunk) => snapshots.push(chunk)
}
})
// Let the initial snapshot round-trip settle.
await Promise.resolve()
await Promise.resolve()
- return { data, snapshots, stream }
+ return { data, metas, snapshots, stream }
}
it('detects a dropped Output frame via the seq gap and resyncs', async () => {
@@ -197,6 +219,27 @@ describe('remote terminal frame-drop resync', () => {
expect(snapshots).toEqual(['INITIAL'])
})
+ it('delivers an empty transformed span with its raw sequence metadata', async () => {
+ const { data, metas, snapshots } = await subscribeClient()
+
+ server.outputSpan('', 9)
+
+ expect(data).toEqual([''])
+ expect(metas).toEqual([{ seq: 9, rawLength: 9, transformed: true }])
+ expect(snapshots).toEqual(['INITIAL'])
+ })
+
+ it('requests an authoritative resync instead of rendering malformed span JSON', async () => {
+ const { data, snapshots } = await subscribeClient()
+
+ server.malformedOutputSpan()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(data).toEqual([])
+ expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED'])
+ })
+
it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => {
const { data, snapshots } = await subscribeClient()
diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
index 7adf6b5dcf2..f4990423409 100644
--- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
+++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
@@ -37,7 +37,7 @@ type TerminalMultiplexEvent =
| { type: string; streamId?: number; [key: string]: unknown }
export type RemoteRuntimeMultiplexedTerminalCallbacks = {
- onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void
+ onData: (data: string, meta?: { seq?: number; rawLength?: number; transformed?: boolean }) => void
onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
onSubscribed?: () => void
onEnd?: () => void
@@ -437,10 +437,42 @@ class RemoteRuntimeTerminalMultiplexer {
if (!stream) {
return
}
- if (frame.opcode === TerminalStreamOpcode.Output) {
- const data = decodeTerminalStreamText(frame.payload)
+ if (
+ frame.opcode === TerminalStreamOpcode.Output ||
+ frame.opcode === TerminalStreamOpcode.OutputSpan
+ ) {
+ const span =
+ frame.opcode === TerminalStreamOpcode.OutputSpan
+ ? decodeTerminalStreamJson<{
+ data?: unknown
+ rawLength?: unknown
+ transformed?: unknown
+ }>(frame.payload)
+ : null
+ const validSpan =
+ frame.opcode !== TerminalStreamOpcode.OutputSpan ||
+ (typeof span?.data === 'string' &&
+ typeof span.rawLength === 'number' &&
+ Number.isSafeInteger(span.rawLength) &&
+ span.rawLength >= 0 &&
+ span.transformed === true)
+ const data =
+ frame.opcode === TerminalStreamOpcode.OutputSpan
+ ? validSpan
+ ? (span!.data as string)
+ : ''
+ : decodeTerminalStreamText(frame.payload)
try {
- const rawLength = data.length
+ if (!validSpan) {
+ // Why: rendering malformed span JSON would expose protocol framing
+ // as terminal text and lose its raw sequence accounting.
+ this.requestResyncSnapshot(stream)
+ return
+ }
+ const rawLength =
+ frame.opcode === TerminalStreamOpcode.OutputSpan && typeof span?.rawLength === 'number'
+ ? span.rawLength
+ : data.length
// Why: a resync snapshot is authoritative; discard live output while
// it is in flight, but still return transport credit in finally.
if (stream.resyncInFlight) {
@@ -454,7 +486,11 @@ class RemoteRuntimeTerminalMultiplexer {
if (typeof seq === 'number') {
stream.expectedSeq = seq
}
- stream.callbacks.onData(data, { seq, rawLength })
+ stream.callbacks.onData(data, {
+ seq,
+ rawLength,
+ ...(frame.opcode === TerminalStreamOpcode.OutputSpan ? { transformed: true } : {})
+ })
} finally {
if (stream.acknowledgeOutput) {
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
diff --git a/src/shared/pty-startup-ingress-intent.ts b/src/shared/pty-startup-ingress-intent.ts
new file mode 100644
index 00000000000..8b58c1183b6
--- /dev/null
+++ b/src/shared/pty-startup-ingress-intent.ts
@@ -0,0 +1,52 @@
+import {
+ terminalOscColorQueryReplies,
+ type TerminalOscColorQueryReplyColors
+} from './terminal-osc-color-reply'
+
+export type PtyStartupIngressIntent = {
+ colors: TerminalOscColorQueryReplyColors
+ deadlineMs: number
+ echoProjection?: 'windows-conpty-esc-stripped'
+}
+
+export const PTY_STARTUP_INGRESS_VERSION = 1
+
+export function parsePtyStartupIngressIntent(
+ value: unknown,
+ options: { allowWindowsEchoProjection: boolean }
+): PtyStartupIngressIntent | undefined {
+ if (!value || typeof value !== 'object') {
+ return undefined
+ }
+ const record = value as Record
+ const colors = record.colors
+ if (!colors || typeof colors !== 'object') {
+ return undefined
+ }
+ const colorRecord = colors as Record
+ const normalizedColors = {
+ ...(typeof colorRecord.foreground === 'string' ? { foreground: colorRecord.foreground } : {}),
+ ...(typeof colorRecord.background === 'string' ? { background: colorRecord.background } : {})
+ }
+ if (
+ !terminalOscColorQueryReplies(normalizedColors, [10, 11]) ||
+ typeof record.deadlineMs !== 'number' ||
+ !Number.isFinite(record.deadlineMs) ||
+ record.deadlineMs < 0 ||
+ record.deadlineMs > 30_000
+ ) {
+ return undefined
+ }
+ const projection = record.echoProjection
+ if (
+ projection !== undefined &&
+ (projection !== 'windows-conpty-esc-stripped' || !options.allowWindowsEchoProjection)
+ ) {
+ return undefined
+ }
+ return {
+ colors: normalizedColors,
+ deadlineMs: record.deadlineMs,
+ ...(projection === 'windows-conpty-esc-stripped' ? { echoProjection: projection } : {})
+ }
+}
diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts
new file mode 100644
index 00000000000..55268f04f7b
--- /dev/null
+++ b/src/shared/pty-startup-ingress.test.ts
@@ -0,0 +1,158 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ PtyStartupIngress,
+ parsePtyStartupIngressIntent,
+ type PtyIngressEmission
+} from './pty-startup-ingress'
+
+const COLORS = { foreground: '#2e3434', background: '#ffffff' }
+
+function createHarness(options: { projection?: boolean; nested?: (data: string) => void } = {}) {
+ const emissions: PtyIngressEmission[] = []
+ let ingress!: PtyStartupIngress
+ const writes: string[] = []
+ ingress = new PtyStartupIngress({
+ intent: {
+ colors: COLORS,
+ deadlineMs: 5_000,
+ ...(options.projection ? { echoProjection: 'windows-conpty-esc-stripped' as const } : {})
+ },
+ write: (data) => {
+ writes.push(data)
+ options.nested?.(data)
+ },
+ onEmission: (emission) => emissions.push(emission)
+ })
+ return { ingress, writes, emissions }
+}
+
+function visible(emissions: readonly PtyIngressEmission[]): string {
+ return emissions.map((emission) => emission.data).join('')
+}
+
+describe('PtyStartupIngress', () => {
+ afterEach(() => vi.useRealTimers())
+
+ it('validates intent bounds and rejects a Windows projection on isolated hosts', () => {
+ const intent = {
+ colors: COLORS,
+ deadlineMs: 5_000,
+ echoProjection: 'windows-conpty-esc-stripped'
+ }
+ expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: true })).toEqual(
+ intent
+ )
+ expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: false })).toBe(
+ undefined
+ )
+ expect(
+ parsePtyStartupIngressIntent(
+ { ...intent, deadlineMs: 30_001 },
+ {
+ allowWindowsEchoProjection: true
+ }
+ )
+ ).toBeUndefined()
+ })
+
+ it('recognizes BEL/ST queries at every split and emits canonical replies', () => {
+ const query = '\x1b]10;?\x07\x1b]11;?\x1b\\'
+ for (let split = 0; split <= query.length; split += 1) {
+ const { ingress, writes, emissions } = createHarness()
+ ingress.accept(query.slice(0, split))
+ ingress.accept(query.slice(split))
+ ingress.drainAndClose()
+ expect(visible(emissions), `split ${split}`).toBe('')
+ expect(writes, `split ${split}`).toEqual([
+ '\x1b]10;rgb:2e2e/3434/3434\x1b\\',
+ '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
+ ])
+ expect(emissions.reduce((sum, item) => sum + item.rawEndSeq - item.rawStartSeq, 0)).toBe(
+ query.length
+ )
+ }
+ })
+
+ it('suppresses the first echo immediately and keeps a later exact collision', () => {
+ const { ingress, emissions } = createHarness({ projection: true })
+ ingress.accept('\x1b]10;?\x07')
+ const projected = ']10;rgb:2e2e/3434/3434\\'
+ ingress.accept(projected)
+ ingress.accept(projected)
+ ingress.drainAndClose()
+ expect(visible(emissions)).toBe(projected)
+ })
+
+ it('matches each echo across every split without skipping an earlier FIFO candidate', () => {
+ const foregroundEcho = ']10;rgb:2e2e/3434/3434\\'
+ const backgroundEcho = ']11;rgb:ffff/ffff/ffff\\'
+ for (const projected of [foregroundEcho, backgroundEcho]) {
+ for (let split = 0; split <= projected.length; split += 1) {
+ const { ingress, emissions } = createHarness({ projection: true })
+ ingress.accept(projected === foregroundEcho ? '\x1b]10;?\x07' : '\x1b]11;?\x1b\\')
+ ingress.accept(projected.slice(0, split))
+ ingress.accept(projected.slice(split))
+ ingress.drainAndClose()
+ expect(visible(emissions), `${projected.slice(0, 3)} split ${split}`).toBe('')
+ }
+ }
+
+ const fifo = createHarness({ projection: true })
+ fifo.ingress.accept('\x1b]10;?;?\x1b\\')
+ fifo.ingress.accept(backgroundEcho)
+ fifo.ingress.accept(backgroundEcho)
+ fifo.ingress.drainAndClose()
+ expect(visible(fifo.emissions)).toBe(backgroundEcho)
+ })
+
+ it('releases partial echo bytes on mismatch, timeout, and snapshot barrier', () => {
+ vi.useFakeTimers()
+ const mismatch = createHarness({ projection: true })
+ mismatch.ingress.accept('\x1b]10;?\x07')
+ mismatch.ingress.accept(']10;rgb:2e2e/nope')
+ expect(visible(mismatch.emissions)).toBe(']10;rgb:2e2e/nope')
+
+ const timeout = createHarness({ projection: true })
+ timeout.ingress.accept('\x1b]10;?\x07')
+ timeout.ingress.accept(']10;rgb:2e2e/')
+ vi.advanceTimersByTime(5_000)
+ expect(visible(timeout.emissions)).toBe(']10;rgb:2e2e/')
+
+ const snapshot = createHarness({ projection: true })
+ snapshot.ingress.accept('\x1b]10;?\x07')
+ snapshot.ingress.accept(']10;rgb:2e2e/')
+ snapshot.ingress.snapshotBarrier()
+ expect(visible(snapshot.emissions)).toBe(']10;rgb:2e2e/')
+
+ snapshot.ingress.accept('\x1b]11;?\x07')
+ expect(snapshot.writes.at(-1)).toBe('\x1b]11;rgb:ffff/ffff/ffff\x1b\\')
+ })
+
+ it('serializes a synchronous nested provider callback after the consumed query span', () => {
+ const emissions: PtyIngressEmission[] = []
+ let ingress!: PtyStartupIngress
+ ingress = new PtyStartupIngress({
+ intent: { colors: COLORS, deadlineMs: 5_000 },
+ write: () => ingress.accept('nested'),
+ onEmission: (emission) => emissions.push(emission)
+ })
+ ingress.accept('before\x1b]10;?\x07after')
+ ingress.drainAndClose()
+ expect(emissions.map(({ data, transformed }) => ({ data, transformed }))).toEqual([
+ { data: 'before', transformed: false },
+ { data: '', transformed: true },
+ { data: 'after', transformed: false },
+ { data: 'nested', transformed: false }
+ ])
+ })
+
+ it('ignores callbacks after teardown without recreating the raw sequence domain', () => {
+ const { ingress, emissions } = createHarness({ projection: true })
+ ingress.accept('\x1b]10;?\x07')
+ ingress.accept(']10;rgb:2e2e/')
+ const closedAt = ingress.drainAndClose()
+ ingress.accept('late')
+ expect(ingress.acceptedRawSequence).toBe(closedAt)
+ expect(visible(emissions)).toBe(']10;rgb:2e2e/')
+ })
+})
diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts
new file mode 100644
index 00000000000..56042a6c52d
--- /dev/null
+++ b/src/shared/pty-startup-ingress.ts
@@ -0,0 +1,340 @@
+import {
+ parseTerminalOscColorQuery,
+ terminalOscColorQueryReplies,
+ type TerminalOscColorQuerySlot
+} from './terminal-osc-color-reply'
+import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
+
+export {
+ PTY_STARTUP_INGRESS_VERSION,
+ parsePtyStartupIngressIntent
+} from './pty-startup-ingress-intent'
+export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent'
+
+export type PtyIngressEmission = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+ transformed: boolean
+}
+
+type PtyIngressSourceChunk = {
+ data: string
+ rawStartSeq: number
+ rawEndSeq: number
+}
+
+type PendingOperation =
+ | { kind: 'data'; chunk: PtyIngressSourceChunk }
+ | { kind: 'close-query' }
+ | { kind: 'snapshot' }
+ | { kind: 'teardown' }
+ | { kind: 'expire' }
+
+type PendingSpan = PtyIngressSourceChunk
+
+export type PtyStartupIngressOptions = {
+ intent?: PtyStartupIngressIntent
+ write: (data: string) => void
+ onEmission: (emission: PtyIngressEmission) => void
+}
+
+const MAX_QUERY_CANDIDATE_CHARS = 64
+
+function spanSlice(span: PendingSpan, start: number, end = span.data.length): PendingSpan {
+ return {
+ data: span.data.slice(start, end),
+ rawStartSeq: span.rawStartSeq + start,
+ rawEndSeq: span.rawStartSeq + end
+ }
+}
+
+function combineSpans(first: PendingSpan | null, second: PendingSpan): PendingSpan {
+ if (!first) {
+ return second
+ }
+ return {
+ data: first.data + second.data,
+ rawStartSeq: first.rawStartSeq,
+ rawEndSeq: second.rawEndSeq
+ }
+}
+
+function projectedWindowsConptyReply(reply: string): string {
+ // Why: the native provider harness observes ConPTY's cooked echo with ESC removed.
+ return reply.replaceAll('\x1b', '')
+}
+
+/**
+ * Serialized source-side startup classifier. Its raw sequence begins after
+ * shell-ready preprocessing and every accepted range is emitted exactly once.
+ */
+export class PtyStartupIngress {
+ private readonly intent: PtyStartupIngressIntent | undefined
+ private readonly writeProvider: (data: string) => void
+ private readonly onEmission: (emission: PtyIngressEmission) => void
+ private readonly operations: PendingOperation[] = []
+ private readonly answeredSlots = new Set()
+ private readonly expectedEchoes: string[] = []
+ private processing = false
+ private closed = false
+ private queryOpen: boolean
+ private rawHighWater = 0
+ private queryPending: PendingSpan | null = null
+ private echoPending: PendingSpan | null = null
+ private deadlineTimer: ReturnType | null = null
+
+ constructor(options: PtyStartupIngressOptions) {
+ this.intent = options.intent
+ this.writeProvider = options.write
+ this.onEmission = options.onEmission
+ this.queryOpen = options.intent !== undefined
+ if (options.intent) {
+ this.deadlineTimer = setTimeout(
+ () => this.enqueue({ kind: 'expire' }),
+ Math.max(0, options.intent.deadlineMs)
+ )
+ this.deadlineTimer.unref?.()
+ }
+ }
+
+ get acceptedRawSequence(): number {
+ return this.rawHighWater
+ }
+
+ accept(data: string): void {
+ if (this.closed || data.length === 0) {
+ return
+ }
+ const rawStartSeq = this.rawHighWater
+ this.rawHighWater += data.length
+ this.enqueue({
+ kind: 'data',
+ chunk: { data, rawStartSeq, rawEndSeq: this.rawHighWater }
+ })
+ }
+
+ closeQueryAuthority(): number {
+ this.enqueue({ kind: 'close-query' })
+ return this.rawHighWater
+ }
+
+ snapshotBarrier(): number {
+ this.enqueue({ kind: 'snapshot' })
+ return this.rawHighWater
+ }
+
+ drainAndClose(): number {
+ this.enqueue({ kind: 'teardown' })
+ return this.rawHighWater
+ }
+
+ private enqueue(operation: PendingOperation): void {
+ if (this.closed) {
+ return
+ }
+ this.operations.push(operation)
+ if (this.processing) {
+ return
+ }
+ this.processing = true
+ try {
+ let next: PendingOperation | undefined
+ while ((next = this.operations.shift())) {
+ this.applyOperation(next)
+ }
+ } finally {
+ this.processing = false
+ }
+ }
+
+ private applyOperation(operation: PendingOperation): void {
+ switch (operation.kind) {
+ case 'data':
+ this.processEchoSpan(operation.chunk)
+ return
+ case 'close-query':
+ this.queryOpen = false
+ this.releaseQueryPending()
+ return
+ case 'expire':
+ this.queryOpen = false
+ this.releaseAllPending()
+ this.expectedEchoes.length = 0
+ this.clearDeadline()
+ return
+ case 'snapshot':
+ this.releaseSnapshotPending()
+ return
+ case 'teardown':
+ this.queryOpen = false
+ this.releaseAllPending()
+ this.expectedEchoes.length = 0
+ this.clearDeadline()
+ this.closed = true
+ }
+ }
+
+ private processEchoSpan(span: PendingSpan): void {
+ let input = combineSpans(this.echoPending, span)
+ this.echoPending = null
+
+ while (this.expectedEchoes.length > 0) {
+ const expected = this.expectedEchoes[0]
+ const compared = Math.min(input.data.length, expected.length)
+ let matching = 0
+ while (matching < compared && input.data[matching] === expected[matching]) {
+ matching += 1
+ }
+ if (matching < compared) {
+ this.expectedEchoes.shift()
+ this.processQuerySpan(input)
+ return
+ }
+ if (input.data.length < expected.length) {
+ this.echoPending = input
+ return
+ }
+
+ this.expectedEchoes.shift()
+ this.emit(spanSlice(input, 0, expected.length), true, '')
+ input = spanSlice(input, expected.length)
+ if (input.data.length === 0) {
+ return
+ }
+ }
+
+ this.processQuerySpan(input)
+ }
+
+ private processQuerySpan(span: PendingSpan): void {
+ const input = combineSpans(this.queryPending, span)
+ this.queryPending = null
+ if (!this.queryOpen || !this.intent) {
+ this.emit(input, false)
+ return
+ }
+
+ let offset = 0
+ while (offset < input.data.length) {
+ const candidateIndex = input.data.indexOf('\x1b', offset)
+ if (candidateIndex === -1) {
+ this.emit(spanSlice(input, offset), false)
+ return
+ }
+ if (candidateIndex > offset) {
+ this.emit(spanSlice(input, offset, candidateIndex), false)
+ }
+ const query = parseTerminalOscColorQuery(input.data, candidateIndex)
+ if (query.kind === 'none') {
+ this.emit(spanSlice(input, candidateIndex, candidateIndex + 1), false)
+ offset = candidateIndex + 1
+ continue
+ }
+ if (query.kind === 'partial') {
+ const candidate = spanSlice(input, candidateIndex)
+ if (candidate.data.length <= MAX_QUERY_CANDIDATE_CHARS) {
+ this.queryPending = candidate
+ } else {
+ this.emit(candidate, false)
+ }
+ return
+ }
+
+ const querySpan = spanSlice(input, candidateIndex, query.endIndex)
+ if (!this.answerQuery(query.slots)) {
+ this.emit(querySpan, false)
+ } else {
+ this.emit(querySpan, true, '')
+ }
+ offset = query.endIndex
+ }
+ }
+
+ private answerQuery(slots: readonly TerminalOscColorQuerySlot[]): boolean {
+ if (slots.some((slot) => this.answeredSlots.has(slot)) || !this.intent) {
+ return false
+ }
+ const replies = terminalOscColorQueryReplies(this.intent.colors, slots)
+ if (!replies) {
+ return false
+ }
+
+ let wroteAny = false
+ for (const [index, reply] of replies.entries()) {
+ const slot = slots[index]
+ if (slot === undefined) {
+ return wroteAny
+ }
+ this.answeredSlots.add(slot)
+ const projected =
+ this.intent.echoProjection === 'windows-conpty-esc-stripped'
+ ? projectedWindowsConptyReply(reply)
+ : null
+ if (projected) {
+ // Why: register before write because node-pty can synchronously re-enter onData.
+ this.expectedEchoes.push(projected)
+ }
+ try {
+ this.writeProvider(reply)
+ wroteAny = true
+ } catch {
+ this.answeredSlots.delete(slot)
+ if (projected) {
+ this.expectedEchoes.pop()
+ }
+ return wroteAny
+ }
+ }
+
+ if (this.answeredSlots.has(10) && this.answeredSlots.has(11)) {
+ this.queryOpen = false
+ }
+ return wroteAny
+ }
+
+ private releaseQueryPending(): void {
+ if (!this.queryPending) {
+ return
+ }
+ const pending = this.queryPending
+ this.queryPending = null
+ this.emit(pending, false)
+ }
+
+ private releaseAllPending(): void {
+ const pending = this.echoPending ?? this.queryPending
+ this.echoPending = null
+ this.queryPending = null
+ if (pending) {
+ this.emit(pending, false)
+ }
+ }
+
+ private releaseSnapshotPending(): void {
+ if (this.echoPending) {
+ const pending = this.echoPending
+ this.echoPending = null
+ this.expectedEchoes.shift()
+ this.emit(pending, false)
+ }
+ this.releaseQueryPending()
+ }
+
+ private emit(span: PendingSpan, transformed: boolean, data = span.data): void {
+ this.onEmission({
+ data,
+ rawStartSeq: span.rawStartSeq,
+ rawEndSeq: span.rawEndSeq,
+ transformed
+ })
+ }
+
+ private clearDeadline(): void {
+ if (!this.deadlineTimer) {
+ return
+ }
+ clearTimeout(this.deadlineTimer)
+ this.deadlineTimer = null
+ }
+}
diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts
index 02314fb39e4..bcfa3b46d9c 100644
--- a/src/shared/terminal-stream-protocol.ts
+++ b/src/shared/terminal-stream-protocol.ts
@@ -20,7 +20,8 @@ export enum TerminalStreamOpcode {
Ack = 13,
// Why 14: Ack already occupies 13 on current clients; older runtimes ignore
// this opcode and still receive the compatibility Resize frame behind it.
- ClaimViewport = 14
+ ClaimViewport = 14,
+ OutputSpan = 15
}
export type TerminalStreamFrame = {
@@ -102,6 +103,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.SnapshotRequest ||
value === TerminalStreamOpcode.Metadata ||
value === TerminalStreamOpcode.Ack ||
- value === TerminalStreamOpcode.ClaimViewport
+ value === TerminalStreamOpcode.ClaimViewport ||
+ value === TerminalStreamOpcode.OutputSpan
)
}
From 7df56adaf1b4b5a062b8f671797d5eb03beae931 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:16:33 -0700
Subject: [PATCH 04/35] fix(ui): restore spaces around orca.yaml in trust
dialog (#9511)
---
.../sidebar/OrcaYamlTrustDialog.test.tsx | 98 +++++++++++++++++++
.../sidebar/OrcaYamlTrustDialog.tsx | 9 +-
src/renderer/src/i18n/locales/es.json | 4 +-
3 files changed, 104 insertions(+), 7 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx
diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx
new file mode 100644
index 00000000000..65f937da42b
--- /dev/null
+++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx
@@ -0,0 +1,98 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import type { ButtonHTMLAttributes, ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ state: {
+ activeModal: 'confirm-orca-yaml-hooks' as string | null,
+ modalData: {} as Record,
+ closeModal: vi.fn(),
+ markOrcaHookScriptConfirmed: vi.fn(),
+ markOrcaHookRepoAlwaysTrusted: vi.fn()
+ }
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign(
+ (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
+ {
+ getState: () => mocks.state
+ }
+ )
+}))
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode }) => {children}
,
+ DialogDescription: ({ children }: { children: ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: ReactNode }) => {children}
,
+ DialogHeader: ({ children }: { children: ReactNode }) => {children}
,
+ DialogTitle: ({ children }: { children: ReactNode }) => {children}
+}))
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ ...props
+ }: ButtonHTMLAttributes & { children: ReactNode }) => (
+
+ )
+}))
+
+// Why: fall back to English defaults so this test doesn't depend on locale files
+// being loaded; the bug is missing JSX whitespace around those fragments.
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+function decodeHtml(html: string): string {
+ return html
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+}
+
+describe('OrcaYamlTrustDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.state.activeModal = 'confirm-orca-yaml-hooks'
+ mocks.state.modalData = {
+ repoId: 'repo-1',
+ repoName: 'orca',
+ scriptKind: 'setup',
+ scriptContent: 'node config/scripts/run-internal-dev-setup.mjs\npnpm install',
+ contentHash: 'hash-1',
+ previouslyApproved: false
+ }
+ })
+
+ it('keeps spaces around orca.yaml and the repo name in the first-run copy', async () => {
+ const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog')
+ const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '')
+
+ expect(text).toContain("This repository's orca.yaml runs on your machine")
+ expect(text).toContain('Only run if you trust orca.')
+ expect(text).toContain('Always trust orca.yaml in orca')
+ expect(text).not.toContain("repository'sorca.yaml")
+ expect(text).not.toContain('trustorca')
+ expect(text).not.toContain('trustorca.yaml')
+ expect(text).not.toContain('inorca')
+ })
+
+ it('keeps spaces around orca.yaml when the script changed since last approval', async () => {
+ mocks.state.modalData = {
+ ...mocks.state.modalData,
+ previouslyApproved: true
+ }
+ const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog')
+ const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '')
+
+ expect(text).toContain('orca.yaml changed since you last approved')
+ expect(text).toContain('Always trust orca.yaml in orca')
+ expect(text).not.toContain('Always trustorca.yaml')
+ expect(text).not.toContain('inorca')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx
index 63489c8b282..60ea9bcf90f 100644
--- a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx
+++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx
@@ -136,7 +136,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
{translate(
'auto.components.sidebar.OrcaYamlTrustDialog.aa3ffb33fb',
"This repository's"
- )}
+ )}{' '}
{translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')}
{' '}
@@ -148,7 +148,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
{translate(
'auto.components.sidebar.OrcaYamlTrustDialog.bf800b7e04',
'. Only run if you trust'
- )}
+ )}{' '}
{repoName}.
>
)}
@@ -190,12 +190,11 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
onChange={(event) => setAlwaysTrust(event.target.checked)}
/>
- {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')}
+ {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')}{' '}
{translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')}
{' '}
- {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')}
- {repoName}
+ {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')} {repoName}
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 1565076a578..64f47a9c258 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -3903,9 +3903,9 @@
"c494b3ccb1": "en",
"79afc6772b": "orca.yaml",
"531689199b": "Confiar siempre",
- "bf800b7e04": ". Ejecútalo solo si confías en ",
+ "bf800b7e04": ". Ejecútalo solo si confías en",
"831f2cd9f0": "se ejecuta en tu máquina",
- "aa3ffb33fb": "El archivo ",
+ "aa3ffb33fb": "El archivo",
"c55beddbf8": "cambió desde la última vez que lo aprobaste. Revísalo de nuevo antes de que se ejecute",
"95bf974a1a": "Script {{value0}}",
"9e52effffd": "Nuevo script {{value0}}",
From 0d97653e3928f511cc28cb7a446c1985126e11fd Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:24:24 -0700
Subject: [PATCH 05/35] fix(ui): restore missing spaces in i18n JSX fragment
copy (#9513)
---
.../src/components/GitHubItemDialog.tsx | 5 +-
.../src/components/GitLabItemDialog.tsx | 4 +-
.../src/components/PullRequestPage.tsx | 5 +-
src/renderer/src/components/TaskPage.tsx | 2 +-
src/renderer/src/components/UpdateCard.tsx | 3 +-
.../automations/AutomationsPage.tsx | 2 +-
.../ExternalAutomationRunTable.tsx | 2 +-
.../ContextualTourProgressDots.tsx | 2 +-
.../components/editor/CombinedDiffViewer.tsx | 2 +-
.../components/editor/ConflictComponents.tsx | 6 +-
.../src/components/editor/IpynbViewer.tsx | 2 +-
.../src/components/editor/MermaidBlock.tsx | 3 +-
.../editor/RichMarkdownDocLinkMenu.tsx | 4 +-
.../ReviewNotesAnimatedVisual.tsx | 2 +-
.../ReviewPRViewAnimatedVisual.tsx | 2 +-
.../feature-wall/ReviewShipAnimatedVisual.tsx | 2 +-
.../feature-wall/review-notes-diff-rows.tsx | 2 +-
.../components/github/PRFilterSections.tsx | 3 +-
.../github/github-rate-limit-display.tsx | 4 +-
.../gitlab/gitlab-rate-limit-display.tsx | 2 +-
.../src/components/linear-priority-icon.tsx | 9 +-
.../right-sidebar/FileExplorerTreeStatus.tsx | 2 +-
.../SourceControlAgentActionDialogForm.tsx | 2 +-
.../right-sidebar/checks-panel-content.tsx | 4 +-
.../src/components/settings/AccountsPane.tsx | 4 +-
.../AutoRenameBranchFromWorkSetting.tsx | 8 +-
.../src/components/settings/BaseRefPicker.tsx | 4 +-
.../settings/BrowserSessionCookiesSection.tsx | 2 +-
.../src/components/settings/CliSection.tsx | 2 +-
.../settings/CommitMessageAiPane.tsx | 2 +-
.../settings/GeneralEditorSettingsSection.tsx | 2 +-
.../settings/GeneralUpdateSettingsSection.tsx | 4 +-
.../src/components/settings/GitPane.tsx | 9 +-
.../settings/ManageSessionKillDialog.tsx | 2 +-
.../settings/NotificationSoundSection.tsx | 2 +-
.../settings/RepositoryHooksSection.tsx | 6 +-
.../components/settings/RepositoryPane.tsx | 2 +-
...orySourceControlAiHostedReviewDefaults.tsx | 2 +-
.../settings/RuntimeEnvironmentsPane.tsx | 2 +-
.../settings/SshPassphraseDialog.tsx | 4 +-
.../settings/WslCliRegistration.tsx | 2 +-
.../sidebar/DeleteWorktreeWarningPanels.tsx | 4 +-
.../sidebar/SetupScriptPromptCardViews.tsx | 6 +-
.../src/components/stats/ShareUsageCard.tsx | 2 +-
.../components/stats/UsageOverviewPane.tsx | 2 +-
.../src/lib/i18n-jsx-spacing-guard.test.ts | 140 ++++++++++++++++++
46 files changed, 212 insertions(+), 77 deletions(-)
create mode 100644 src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 6f6b7fb2424..5d98a84f0f1 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -4935,13 +4935,12 @@ function ChecksTab({
{startedAt && (
- {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')}
- {startedAt}
+ {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt}
)}
{completedAt && (
- {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}
+ {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '}
{completedAt}
)}
diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx
index b71386b4c34..827d86ad2d8 100644
--- a/src/renderer/src/components/GitLabItemDialog.tsx
+++ b/src/renderer/src/components/GitLabItemDialog.tsx
@@ -1034,7 +1034,7 @@ export default function GitLabItemDialog({
{item.author ? (
- {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')}
+ {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')}{' '}
{item.author}
) : null}
@@ -1521,7 +1521,7 @@ export default function GitLabItemDialog({
{translate(
'auto.components.GitLabItemDialog.a7eb4f4916',
'from'
- )}
+ )}{' '}
{file.oldPath}
) : null}
diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx
index b53ba354618..8cd3f872974 100644
--- a/src/renderer/src/components/PullRequestPage.tsx
+++ b/src/renderer/src/components/PullRequestPage.tsx
@@ -5030,13 +5030,12 @@ function ChecksTab({
{startedAt && (
- {translate('auto.components.PullRequestPage.76551b1161', 'Started')}
- {startedAt}
+ {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt}
)}
{completedAt && (
- {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}
+ {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '}
{completedAt}
)}
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 51bbc201879..727c0a44970 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -9323,7 +9323,7 @@ export default function TaskPage(): React.JSX.Element {
// Why: per-repo partial-failure signal — distinct from a hard
// IPC reject (tasksError). The two are mutually exclusive.
- {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}
+ {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}{' '}
{selectedRepos.length}{' '}
{translate('auto.components.TaskPage.d1766fd62d', 'projects failed to load')}
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx
index 9010a740e10..c080b518f64 100644
--- a/src/renderer/src/components/UpdateCard.tsx
+++ b/src/renderer/src/components/UpdateCard.tsx
@@ -887,8 +887,7 @@ function DownloadingContent({
{release ? (
- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')}
- {release.title}
+ {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}
) : (
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index 98a78ef9845..538cec979e3 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -2877,7 +2877,7 @@ export default function AutomationsPage(): React.JSX.Element {
)}
- {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}
+ {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
{selectedRuns.length}
diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
index aa8ff9666c7..f6e5f693371 100644
--- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
+++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
@@ -288,7 +288,7 @@ export function ExternalAutomationRunTable({
{pageStart}-{pageEnd}{' '}
- {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}
+ {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '}
{totalCount}
diff --git a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx
index b52cbb6fdf4..bb7fde46fe1 100644
--- a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx
+++ b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx
@@ -46,7 +46,7 @@ export function ContextualTourProgressDots({
{current}{' '}
- {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')}
+ {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')}{' '}
{total}
diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx
index 9ae116de477..7622980a231 100644
--- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx
+++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx
@@ -2022,7 +2022,7 @@ export default function CombinedDiffViewer({
{translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')}
- {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}
+ {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '}
{diffCommentCount}{' '}
{diffCommentCount === 1
? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note')
diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx
index 47c74ae88db..32159e23f00 100644
--- a/src/renderer/src/components/editor/ConflictComponents.tsx
+++ b/src/renderer/src/components/editor/ConflictComponents.tsx
@@ -101,7 +101,7 @@ export function ConflictBanner({
)}
{label}{' '}
- {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')}
+ {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')}{' '}
{CONFLICT_KIND_LABELS[conflict.conflictKind]}
{conflictNavigation && conflictNavigation.total > 0 && (
@@ -171,7 +171,7 @@ export function ConflictBanner({
)}
{entry?.oldPath && (
- {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')}
+ {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')}{' '}
{entry.oldPath}
)}
@@ -334,7 +334,7 @@ export function ConflictReviewPanel({
{translate(
'auto.components.editor.ConflictComponents.a1ce36f77d',
'Snapshot captured at'
- )}
+ )}{' '}
{snapshotTime}.
diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx
index fc19ca04175..ecb20f2ff63 100644
--- a/src/renderer/src/components/editor/IpynbViewer.tsx
+++ b/src/renderer/src/components/editor/IpynbViewer.tsx
@@ -899,7 +899,7 @@ export default function IpynbViewer({
{translate('auto.components.editor.IpynbViewer.329764e9fc', 'BETA')}
- {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')}
+ {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')}{' '}
{notebook.nbformat}
diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx
index 250a0c32161..f25de3983ff 100644
--- a/src/renderer/src/components/editor/MermaidBlock.tsx
+++ b/src/renderer/src/components/editor/MermaidBlock.tsx
@@ -103,8 +103,7 @@ export default function MermaidBlock({
return (
- {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')}
- {error}
+ {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} {error}
{content}
diff --git a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
index d339f1e3da6..dafc70c658b 100644
--- a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
@@ -68,9 +68,9 @@ export function RichMarkdownDocLinkMenu({
)}
{overflow ? (
- {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')}
+ {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')}{' '}
{rows.length}{' '}
- {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')}
+ {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')}{' '}
{totalMatches}
) : null}
diff --git a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx
index 24c8399761e..ba9684ba708 100644
--- a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx
+++ b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx
@@ -381,7 +381,7 @@ export function ReviewNotesAnimatedVisual(props: { reducedMotion: boolean }): JS
- {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')}
+ {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')}{' '}
?
diff --git a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx
index be3148a0e7c..461da2f21c6 100644
--- a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx
+++ b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx
@@ -272,7 +272,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7',
'Add a coverage case for'
- )}
+ )}{' '}
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec',
diff --git a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx
index 0b2ef880ffa..548ac8334f2 100644
--- a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx
+++ b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx
@@ -303,7 +303,7 @@ export function ReviewShipAnimatedVisual(props: { reducedMotion: boolean }): JSX
{translate(
'auto.components.feature.wall.ReviewShipAnimatedVisual.e725000cd7',
'Changes'
- )}
+ )}{' '}
{SHIP_FILES.length}
diff --git a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx
index c5c476a39d2..cbba8da521c 100644
--- a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx
+++ b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx
@@ -85,7 +85,7 @@ export function ReviewDiffRows(): JSX.Element {
{translate(
'auto.components.feature.wall.review.notes.diff.rows.f621c734f8',
'Note · line'
- )}
+ )}{' '}
?
diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx
index 59fc7634120..6f65eee8e55 100644
--- a/src/renderer/src/components/github/PRFilterSections.tsx
+++ b/src/renderer/src/components/github/PRFilterSections.tsx
@@ -208,8 +208,7 @@ export function SectionMenu({
return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')}
- {subject}
+ {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject}
{rows.map((row) => (
diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx
index 6bc87d967e8..a85c566b0d6 100644
--- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx
+++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx
@@ -133,7 +133,7 @@ function GitLabRateLimitRows({
)}
>
{rest.remaining}{' '}
- {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')}
+ {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')}{' '}
{rest.limit}{' '}
{translate(
'auto.components.gitlab.gitlab.rate.limit.display.3e2c982cfa',
diff --git a/src/renderer/src/components/linear-priority-icon.tsx b/src/renderer/src/components/linear-priority-icon.tsx
index ae9ebbb16a8..92f8bfaa210 100644
--- a/src/renderer/src/components/linear-priority-icon.tsx
+++ b/src/renderer/src/components/linear-priority-icon.tsx
@@ -54,8 +54,7 @@ export function LinearPriorityIcon({
>
!
- {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')}
- {label}
+ {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label}
)
@@ -72,8 +71,7 @@ export function LinearPriorityIcon({
className="size-3 rounded-full border border-muted-foreground/55"
/>
- {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')}
- {label}
+ {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label}
)
@@ -110,8 +108,7 @@ export function LinearPriorityIcon({
})}
- {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')}
- {label}
+ {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label}
)
diff --git a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx
index 7f8d3824151..58fa294e5da 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx
@@ -29,7 +29,7 @@ export function FileExplorerTreeStatus({
{translate(
'auto.components.right.sidebar.FileExplorerTreeStatus.c76693e456',
'Could not load files for this workspace:'
- )}
+ )}{' '}
{error}
)
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
index 4f2caf0133b..8f9fff3e657 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
@@ -378,7 +378,7 @@ export function SourceControlAgentActionDialogForm({
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
'Launch:'
- )}
+ )}{' '}
{deliveryPlan.commandLabel}
{deliveryPlan.caveat}
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index 84798c944f8..0dd54d7b7dd 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -735,7 +735,7 @@ function CheckRunDetails({
{translate(
'auto.components.right.sidebar.checks.panel.content.fd46a70f1a',
'Started'
- )}
+ )}{' '}
{startedAt}
)}
@@ -744,7 +744,7 @@ function CheckRunDetails({
{translate(
'auto.components.right.sidebar.checks.panel.content.00e1c1658a',
'Completed'
- )}
+ )}{' '}
{completedAt}
)}
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 5268a890e36..4735f0b06c5 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -1501,14 +1501,14 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.0023cc336e',
'Paste either the raw token value (e.g.'
- )}
+ )}{' '}
{translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')}
{translate(
'auto.components.settings.AccountsPane.338820326a',
') or the full cookie header (e.g.'
- )}
+ )}{' '}
{translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')}
diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
index b8540cdefa9..c4d520cbdb7 100644
--- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
+++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx
@@ -159,7 +159,7 @@ export function AutoRenameBranchFromWorkSetting({
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.12ea4a408d',
'When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.'
- )}
+ )}{' '}
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.1626524572',
@@ -223,7 +223,7 @@ export function AutoRenameBranchFromWorkSetting({
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.9241b59bf5',
'Use'
- )}
+ )}{' '}
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.c71770c455',
@@ -261,7 +261,7 @@ export function AutoRenameBranchFromWorkSetting({
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.56580dcf60',
'. You can also reference'
- )}
+ )}{' '}
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.2ee2779c05',
@@ -281,7 +281,7 @@ export function AutoRenameBranchFromWorkSetting({
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.5d569f5199',
'. Orca generates only the final segment, like'
- )}
+ )}{' '}
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.800edb1e54',
diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx
index b0c0b6e72ef..8bb96a508cc 100644
--- a/src/renderer/src/components/settings/BaseRefPicker.tsx
+++ b/src/renderer/src/components/settings/BaseRefPicker.tsx
@@ -195,14 +195,14 @@ export function BaseRefPicker({
{translate(
'auto.components.settings.BaseRefPicker.a5c16712c1',
'Multiple remotes detected. Type a remote name (e.g.'
- )}
+ )}{' '}
{translate('auto.components.settings.BaseRefPicker.915ad97875', 'upstream')}
{translate(
'auto.components.settings.BaseRefPicker.80f7c82303',
') or a full ref (e.g.'
- )}
+ )}{' '}
{translate('auto.components.settings.BaseRefPicker.b468f46726', 'upstream/main')}
diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx
index f1c39ca8fec..8fa1e6c9a60 100644
--- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx
+++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx
@@ -72,7 +72,7 @@ export function BrowserSessionCookiesSection({
{translate(
'auto.components.settings.BrowserPane.cd47bc9622',
'Select a default profile for new browser tabs. Import cookies and switch profiles per-tab via the'
- )}
+ )}{' '}
···{' '}
{translate('auto.components.settings.BrowserPane.e4aaf8051b', 'toolbar menu.')}
diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx
index 5f61d1e8ef3..6f6eab2ee94 100644
--- a/src/renderer/src/components/settings/CliSection.tsx
+++ b/src/renderer/src/components/settings/CliSection.tsx
@@ -317,7 +317,7 @@ export function CliSection({
{translate(
'auto.components.settings.CliSection.b0c310ab46',
'Existing launcher target:'
- )}
+ )}{' '}
{status.currentTarget}
) : null}
diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
index 9b8ebc6d9ff..e24fc97ee40 100644
--- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx
+++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
@@ -273,7 +273,7 @@ export function CommitMessageAiPane({
{translate(
'auto.components.settings.CommitMessageAiPane.4f722a5f53',
'Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use'
- )}
+ )}{' '}
{translate('auto.components.settings.CommitMessageAiPane.b8b6fd55b4', '{prompt}')}
{' '}
diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx
index 9cccdf166ee..2a524600b07 100644
--- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx
+++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx
@@ -170,7 +170,7 @@ export function GeneralEditorSettingsSection({
{translate(
'auto.components.settings.GeneralEditorSettingsSection.8112cd6dcf',
'How long Orca waits after your last edit before saving automatically. First launch defaults to'
- )}
+ )}{' '}
{DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS}{' '}
{translate('auto.components.settings.GeneralEditorSettingsSection.fc5c5306ff', 'ms.')}
diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
index 51668b1ac12..da1984cca0e 100644
--- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
+++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
@@ -161,7 +161,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc',
'Version'
- )}
+ )}{' '}
{updateStatus.version}{' '}
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba',
@@ -199,7 +199,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc',
'Version'
- )}
+ )}{' '}
{updateStatus.version}{' '}
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89',
diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx
index 6d346fb1de6..bdfff4a6967 100644
--- a/src/renderer/src/components/settings/GitPane.tsx
+++ b/src/renderer/src/components/settings/GitPane.tsx
@@ -236,11 +236,14 @@ export function GitPane({
{translate(
'auto.components.settings.GitPane.976afc6b3e',
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as'
- )}
+ )}{' '}
{translate('auto.components.settings.GitPane.ffba483bae', 'main')}{' '}
- {translate('auto.components.settings.GitPane.5bf885be48', 'or')}
+ {translate('auto.components.settings.GitPane.5bf885be48', 'or')}{' '}
{translate('auto.components.settings.GitPane.3ae3de8898', 'master')}
- {translate('auto.components.settings.GitPane.db3a127eb1', '. This keeps commands like')}
+ {translate(
+ 'auto.components.settings.GitPane.db3a127eb1',
+ '. This keeps commands like'
+ )}{' '}
{translate('auto.components.settings.GitPane.d072a12995', 'git diff main...HEAD')}
{' '}
diff --git a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx
index 56ddb9845ee..04c795f3748 100644
--- a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx
+++ b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx
@@ -66,7 +66,7 @@ export function ManageSessionKillDialog({
{translate(
'auto.components.settings.ManageSessionKillDialog.8401328fed',
'Force-quits'
- )}
+ )}{' '}
{session.sessionId}
{translate(
'auto.components.settings.ManageSessionKillDialog.ad9832aa26',
diff --git a/src/renderer/src/components/settings/NotificationSoundSection.tsx b/src/renderer/src/components/settings/NotificationSoundSection.tsx
index 0d0b92279e7..da1fe4e11cc 100644
--- a/src/renderer/src/components/settings/NotificationSoundSection.tsx
+++ b/src/renderer/src/components/settings/NotificationSoundSection.tsx
@@ -159,7 +159,7 @@ export function NotificationSoundSection({
className="truncate font-mono text-[11px] text-muted-foreground"
title={notificationSettings.customSoundPath}
>
- {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')}
+ {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')}{' '}
{notificationSettings.customSoundPath}
) : null}
diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx
index 1ee7cfe19f8..bec7ba52a28 100644
--- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx
+++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx
@@ -441,7 +441,7 @@ function ExampleTemplateCard({
return (
- {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')}
+ {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')}{' '}
{translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')}
{' '}
@@ -665,7 +665,7 @@ function ScriptEditor({
- {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}
+ {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}{' '}
{translate(
'auto.components.settings.RepositoryHooksSection.39da2ae12f',
@@ -1308,7 +1308,7 @@ export function RepositoryHooksSection({
{translate(
'auto.components.settings.RepositoryHooksSection.ac9038d2cc',
'When both'
- )}
+ )}{' '}
{translate(
'auto.components.settings.RepositoryHooksSection.39da2ae12f',
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
index bb178d3c17f..a90c94e5ea8 100644
--- a/src/renderer/src/components/settings/RepositoryPane.tsx
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -226,7 +226,7 @@ export function RepositoryPane({
)}
- {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')}
+ {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')}{' '}
{getRepoKindLabel(repo)}
{isFolder ? (
diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx
index 7d620ee7ace..07156b38388 100644
--- a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx
+++ b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx
@@ -82,7 +82,7 @@ export function RepositorySourceControlAiHostedReviewDefaults({
{translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.a68849a859',
'Global default is'
- )}
+ )}{' '}
{inherited}.
diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
index 116dc02c5be..af3e2be3ee0 100644
--- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
+++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
@@ -813,7 +813,7 @@ export function RuntimeEnvironmentsPane({
className="h-8 min-w-0 font-mono text-xs"
/>
- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}
+ {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '}
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4',
diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx
index 7cfb4dbd0b8..56fd3c3d374 100644
--- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx
+++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx
@@ -133,7 +133,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null {
{translate(
'auto.components.settings.SshPassphraseDialog.dbf9b6f2d0',
'Enter the password for'
- )}
+ )}{' '}
{label}
>
) : (
@@ -141,7 +141,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null {
{translate(
'auto.components.settings.SshPassphraseDialog.ce4fdf7914',
'Enter the passphrase for'
- )}
+ )}{' '}
{label}
>
)}
diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx
index 9d23dbeca93..7d01d96d58b 100644
--- a/src/renderer/src/components/settings/WslCliRegistration.tsx
+++ b/src/renderer/src/components/settings/WslCliRegistration.tsx
@@ -217,7 +217,7 @@ export function WslCliRegistration({
{translate(
'auto.components.settings.WslCliRegistration.1dbb0377d9',
'Existing launcher target:'
- )}
+ )}{' '}
{status.currentTarget}
) : null}
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx
index 89b22f6c841..b143148249c 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx
@@ -21,7 +21,7 @@ export function DeleteWorktreeWarningPanels({
{translate(
'auto.components.sidebar.DeleteWorktreeWarningPanels.e3be9eba15',
'This is the'
- )}
+ )}{' '}
{translate(
'auto.components.sidebar.DeleteWorktreeWarningPanels.c4f96a6e18',
@@ -32,7 +32,7 @@ export function DeleteWorktreeWarningPanels({
'auto.components.sidebar.DeleteWorktreeWarningPanels.026738155a',
'(the original clone directory).'
)}
- {mainWorktreeBlocker}
+ {mainWorktreeBlocker ? <> {mainWorktreeBlocker}> : null}
diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx
index 45f2947d3ed..25a960bff8c 100644
--- a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx
+++ b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx
@@ -71,7 +71,7 @@ export function DetectedSetupPreview({
{translate(
'auto.components.sidebar.SetupScriptPromptCardViews.d02e6a42b1',
'Detected from'
- )}
+ )}{' '}
{provenance}
) : null}
@@ -157,7 +157,7 @@ export function SetupScriptPromptBody({
{translate(
'auto.components.sidebar.SetupScriptPromptCardViews.bb879db364',
'This repo ignores shared'
- )}
+ )}{' '}
{translate('auto.components.sidebar.SetupScriptPromptCardViews.8f6be51aa1', 'orca.yaml')}
{' '}
@@ -184,7 +184,7 @@ export function SetupScriptPromptBody({
{translate(
'auto.components.sidebar.SetupScriptPromptCardViews.b56d1322f7',
'Found a setup command in'
- )}
+ )}{' '}
{candidateSource}
{translate(
'auto.components.sidebar.SetupScriptPromptCardViews.8349e3fa4c',
diff --git a/src/renderer/src/components/stats/ShareUsageCard.tsx b/src/renderer/src/components/stats/ShareUsageCard.tsx
index cb6eb5c92bf..dcd2f673cb2 100644
--- a/src/renderer/src/components/stats/ShareUsageCard.tsx
+++ b/src/renderer/src/components/stats/ShareUsageCard.tsx
@@ -233,7 +233,7 @@ function ChartHeader(props: {
{props.sessions}{' '}
- {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')}
+ {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')}{' '}
{props.turnsOrEvents.count} {props.turnsOrEvents.label}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx
index 1f2080c995d..03001c2bec9 100644
--- a/src/renderer/src/components/stats/UsageOverviewPane.tsx
+++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx
@@ -253,7 +253,7 @@ export function UsageOverviewPane(): React.JSX.Element {
{overview.enabledProviderCount}{' '}
- {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')}
+ {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')}{' '}
{overview.dataProviderCount}{' '}
{translate('auto.components.stats.UsageOverviewPane.444585cb41', 'with data')}
diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts
new file mode 100644
index 00000000000..f2dde24571e
--- /dev/null
+++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts
@@ -0,0 +1,140 @@
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Guard against the i18n fragment glue-word class of bugs:
+ * `translate('…', 'word')}` immediately followed by `` / `{value}` without
+ * an explicit `{' '}` space. Newline indentation alone is not reliable.
+ */
+const ROOT = path.resolve(__dirname, '..')
+
+type GuardCase = {
+ file: string
+ /** English fallback that must be followed by `{' '}` before the next token. */
+ afterFallback: string
+ /** Human-readable description for assertion messages. */
+ label: string
+}
+
+const CASES: GuardCase[] = [
+ {
+ file: 'components/sidebar/SetupScriptPromptCardViews.tsx',
+ afterFallback: 'This repo ignores shared',
+ label: 'shared orca.yaml'
+ },
+ {
+ file: 'components/sidebar/SetupScriptPromptCardViews.tsx',
+ afterFallback: 'Detected from',
+ label: 'Detected from provenance'
+ },
+ {
+ file: 'components/sidebar/SetupScriptPromptCardViews.tsx',
+ afterFallback: 'Found a setup command in',
+ label: 'Found a setup command in source'
+ },
+ {
+ file: 'components/settings/RepositoryHooksSection.tsx',
+ afterFallback: 'Example',
+ label: 'Example orca.yaml template'
+ },
+ {
+ file: 'components/settings/RepositoryHooksSection.tsx',
+ afterFallback: 'Edit',
+ label: 'Edit orca.yaml'
+ },
+ {
+ file: 'components/settings/RepositoryHooksSection.tsx',
+ afterFallback: 'When both',
+ label: 'When both orca.yaml'
+ },
+ {
+ file: 'components/settings/GitPane.tsx',
+ afterFallback: 'or',
+ label: 'main or master'
+ },
+ {
+ file: 'components/settings/GitPane.tsx',
+ afterFallback: 'such as',
+ label: 'such as main'
+ },
+ {
+ file: 'components/settings/RuntimeEnvironmentsPane.tsx',
+ afterFallback: 'Run',
+ label: 'Run orca serve'
+ },
+ {
+ file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx',
+ afterFallback: 'Use',
+ label: 'Use {basePrompt}'
+ },
+ {
+ file: 'components/settings/CommitMessageAiPane.tsx',
+ afterFallback: 'Use',
+ label: 'Use {prompt}'
+ },
+ {
+ file: 'components/editor/RichMarkdownDocLinkMenu.tsx',
+ afterFallback: 'Showing',
+ label: 'Showing N of M'
+ },
+ {
+ file: 'components/editor/RichMarkdownDocLinkMenu.tsx',
+ afterFallback: 'of',
+ label: 'of totalMatches'
+ },
+ {
+ file: 'components/contextual-tours/ContextualTourProgressDots.tsx',
+ afterFallback: 'of',
+ label: 'tour of total'
+ },
+ {
+ file: 'components/github/PRFilterSections.tsx',
+ afterFallback: 'Filter',
+ label: 'Filter pull requests'
+ },
+ {
+ file: 'components/editor/ConflictComponents.tsx',
+ afterFallback: 'Renamed from',
+ label: 'Renamed from path'
+ },
+ {
+ file: 'components/settings/SshPassphraseDialog.tsx',
+ afterFallback: 'Enter the password for',
+ label: 'password for host'
+ },
+ {
+ file: 'components/settings/ManageSessionKillDialog.tsx',
+ afterFallback: 'Force-quits',
+ label: 'Force-quits session id'
+ },
+ {
+ file: 'components/sidebar/DeleteWorktreeWarningPanels.tsx',
+ afterFallback: 'This is the',
+ label: 'This is the main worktree'
+ }
+]
+
+function hasExplicitSpaceAfterFallback(source: string, fallback: string): boolean {
+ // Match translate fallbacks that end with (or equal) the given phrase, then
+ // require an explicit {' '} after the closing )} so JSX whitespace is not
+ // left to indentation alone.
+ const escaped = fallback.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const pattern = new RegExp(
+ String.raw`translate\(\s*'[^']+',\s*(?:'[^']*${escaped}'|"[^"]*${escaped}")\s*(?:,\s*\{[^}]*\})?\s*\)\s*\}\s*\{\s*['"]\s['"]\s*\}`,
+ 'm'
+ )
+ return pattern.test(source)
+}
+
+describe('i18n JSX fragment spacing guard', () => {
+ for (const testCase of CASES) {
+ it(`keeps an explicit space after "${testCase.label}" in ${testCase.file}`, () => {
+ const source = readFileSync(path.join(ROOT, testCase.file), 'utf8')
+ expect(
+ hasExplicitSpaceAfterFallback(source, testCase.afterFallback),
+ `Expected {' '} immediately after translate fallback "${testCase.afterFallback}" in ${testCase.file}`
+ ).toBe(true)
+ })
+ }
+})
From 74e2cb702ca34f171cdef140358dbf14a49d57f6 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:30:01 -0700
Subject: [PATCH 06/35] perf(window): re-enable macOS main-window background
throttling (#9395)
Co-authored-by: Orca
---
src/main/startup/configure-process.test.ts | 14 ++++
src/main/startup/configure-process.ts | 12 ++++
src/main/window/createMainWindow.test.ts | 74 ++++++++++++++++++++++
src/main/window/createMainWindow.ts | 65 +++++++++++++++----
4 files changed, 151 insertions(+), 14 deletions(-)
diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts
index 72a545a3988..08cd8139e93 100644
--- a/src/main/startup/configure-process.test.ts
+++ b/src/main/startup/configure-process.test.ts
@@ -297,6 +297,20 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu')
})
+ it('opts hidden pages out of intensive wake-up throttling', async () => {
+ const { app } = await import('electron')
+ const { enableMainProcessGpuFeatures } = await import('./configure-process')
+
+ delete process.env.ORCA_E2E_USER_DATA_DIR
+ vi.mocked(app.commandLine.appendSwitch).mockClear()
+ enableMainProcessGpuFeatures()
+
+ expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
+ 'disable-features',
+ 'IntensiveWakeUpThrottling'
+ )
+ })
+
it('raises the WebGL context budget above the 16-context Blink default', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts
index e92c53e4b0d..e033e4e94de 100644
--- a/src/main/startup/configure-process.ts
+++ b/src/main/startup/configure-process.ts
@@ -344,4 +344,16 @@ export function enableMainProcessGpuFeatures(): void {
if (features) {
app.commandLine.appendSwitch('enable-features', features)
}
+
+ const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features')
+ // Why: with main-window background throttling on, Chromium's intensive mode
+ // clamps hidden-page timers to 1/min after 5 minutes, delaying agent-done and
+ // bell notifications by up to 60s. Keep the normal 1s hidden clamp (rAF and
+ // rendering still stop) but opt out of the 1/min tier. Callers skip this
+ // function under GPU fallback (win32-only today); if throttling ever extends
+ // to Windows, this opt-out must move out of the GPU-gated path.
+ const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures]
+ .filter(Boolean)
+ .join(',')
+ app.commandLine.appendSwitch('disable-features', disabledFeatures)
}
diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts
index 8899ba81bbf..3b69b9c1253 100644
--- a/src/main/window/createMainWindow.test.ts
+++ b/src/main/window/createMainWindow.test.ts
@@ -299,6 +299,80 @@ describe('createMainWindow', () => {
}
})
+ it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => {
+ vi.useFakeTimers()
+ const windowHandlers = new Map void)[]>()
+ let windowSize: [number, number] = [1200, 800]
+ const webContents = {
+ on: vi.fn(),
+ setZoomLevel: vi.fn(),
+ setBackgroundThrottling: vi.fn(),
+ invalidate: vi.fn(),
+ isDestroyed: vi.fn(() => false),
+ setWindowOpenHandler: vi.fn(),
+ send: vi.fn(),
+ isDevToolsOpened: vi.fn(),
+ openDevTools: vi.fn(),
+ closeDevTools: vi.fn()
+ }
+ const browserWindowInstance = {
+ webContents,
+ on: vi.fn((event: string, handler: (...args: any[]) => void) => {
+ const handlers = windowHandlers.get(event) ?? []
+ handlers.push(handler)
+ windowHandlers.set(event, handlers)
+ }),
+ isDestroyed: vi.fn(() => false),
+ isMaximized: vi.fn(() => false),
+ isFullScreen: vi.fn(() => false),
+ getSize: vi.fn(() => windowSize),
+ setSize: vi.fn((width: number, height: number) => {
+ windowSize = [width, height]
+ }),
+ maximize: vi.fn(),
+ show: vi.fn(),
+ loadFile: vi.fn(),
+ loadURL: vi.fn()
+ }
+ browserWindowMock.mockImplementation(function () {
+ return browserWindowInstance
+ })
+
+ withPlatform('darwin', () => createMainWindow(null))
+
+ // Why: throttling-off pins visibilityState 'visible' and renders occluded
+ // windows at full rate; this guards against reintroducing it.
+ expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false)
+
+ expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true)
+ expect(windowHandlers.get('restore')).toHaveLength(1)
+ expect(windowHandlers.get('show')).toHaveLength(1)
+ expect(windowHandlers.get('focus')).toHaveLength(1)
+
+ windowHandlers.get('show')?.[0]?.()
+ windowHandlers.get('restore')?.[0]?.()
+
+ expect(webContents.invalidate).toHaveBeenCalledTimes(2)
+ expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800)
+ expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(32)
+ expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(2, 1200, 800)
+
+ vi.advanceTimersByTime(217)
+ expect(webContents.invalidate).toHaveBeenCalledTimes(2)
+
+ vi.advanceTimersByTime(1)
+ expect(webContents.invalidate).toHaveBeenCalledTimes(3)
+
+ // Why: focus covers occlusion-uncover with invalidate only — no setSize
+ // jiggle that would resize terminals on every window focus.
+ const setSizeCalls = browserWindowInstance.setSize.mock.calls.length
+ windowHandlers.get('focus')?.[0]?.()
+ expect(webContents.invalidate).toHaveBeenCalledTimes(4)
+ expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls)
+ })
+
it('supports all minus key variants for terminal zoom out', () => {
const windowHandlers: Record void> = {}
const webContents = {
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index 52c84ec6048..b12583377b1 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -50,23 +50,65 @@ import { buildEditableContextMenuTemplate } from './editable-context-menu'
import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui'
import { resolveWindowCloseAction } from './window-close-decision'
+// Why: show/restore/resume can overlap before the size nudge resets; never
+// capture the temporary width as the next repaint's baseline.
+const activeRepaintJiggles = new WeakSet()
+
function forceRepaint(window: BrowserWindow): void {
- if (window.isDestroyed()) {
+ // Why: webContents can be destroyed a beat before the BrowserWindow during
+ // close, and this runs from timers/focus events that can land in that gap.
+ if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
}
window.webContents.invalidate()
- if (window.isMaximized() || window.isFullScreen()) {
+ if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
return
}
+ activeRepaintJiggles.add(window)
const [width, height] = window.getSize()
window.setSize(width + 1, height)
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
}
+ activeRepaintJiggles.delete(window)
}, 32)
}
+function installMacosVisibilityRepaint(window: BrowserWindow): void {
+ let delayedRepaintTimer: ReturnType | null = null
+ const repaintAfterVisibilityTransition = (): void => {
+ forceRepaint(window)
+ if (delayedRepaintTimer) {
+ clearTimeout(delayedRepaintTimer)
+ }
+ // Why: macOS can finish restoring webview compositor layers after Electron's
+ // show/restore event, so a second paint catches late black-surface recovery.
+ delayedRepaintTimer = setTimeout(() => {
+ delayedRepaintTimer = null
+ forceRepaint(window)
+ }, 250)
+ }
+ const clearDelayedRepaint = (): void => {
+ if (delayedRepaintTimer) {
+ clearTimeout(delayedRepaintTimer)
+ delayedRepaintTimer = null
+ }
+ }
+
+ window.on('restore', repaintAfterVisibilityTransition)
+ window.on('show', repaintAfterVisibilityTransition)
+ // Why: occlusion-uncover fires neither restore nor show; focus is the only
+ // signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal
+ // on each Cmd+Tab.
+ window.on('focus', () => {
+ if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
+ window.webContents.invalidate()
+ }
+ })
+ window.on('closed', clearDelayedRepaint)
+}
+
function isMacAppPasteInput(input: Electron.Input): boolean {
return (
process.platform === 'darwin' &&
@@ -293,18 +335,13 @@ export function createMainWindow(
setTrustedUIRendererWebContentsId(rendererWebContentsId)
if (process.platform === 'darwin') {
- // Why: persistent browser webviews use separate compositor layers, and on
- // recent macOS releases those layers can fail to repaint after occlusion or
- // restore. Disabling main-window throttling and forcing a repaint on
- // visibility transitions hardens Orca against black-surface failures during
- // browser-tab restore and tab switching.
- mainWindow.webContents.setBackgroundThrottling(false)
- mainWindow.on('restore', () => {
- forceRepaint(mainWindow)
- })
- mainWindow.on('show', () => {
- forceRepaint(mainWindow)
- })
+ // Why: browser-guest surfaces are kept alive by their own per-guest
+ // unthrottle (browser-manager attach), so the main window can throttle
+ // normally while hidden/occluded instead of rendering at full rate.
+ // Toggling must happen only while visible: flipping it on a hidden window
+ // desyncs Chromium's frame evictor and blanks the surface (electron#42378).
+ mainWindow.webContents.setBackgroundThrottling(true)
+ installMacosVisibilityRepaint(mainWindow)
}
// Why: a focus-preserving system/display wake fires no window focus or
From e90dc38f01670ff2da0d1eb36fc051961efca09f Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:31:36 -0700
Subject: [PATCH 07/35] fix(dev): stabilize macOS safeStorage Keychain key name
across dev branches (#9400)
Co-authored-by: Orca
---
src/main/index.ts | 7 ++++++-
src/main/menu/register-app-menu.ts | 5 ++++-
src/main/startup/dev-instance-identity.test.ts | 13 +++++++++++++
src/main/startup/dev-instance-identity.ts | 10 ++++++++++
4 files changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/main/index.ts b/src/main/index.ts
index 9278eb043ec..312b8bfd9cf 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1766,7 +1766,11 @@ app.whenReady().then(async () => {
}
)
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
- app.setName(devInstanceIdentity.name)
+ // Why: setName drives the macOS safeStorage Keychain item name. Use the stable
+ // appName (not the per-branch `name`) so dev branches share one key and don't
+ // re-prompt per branch; the per-branch label still shows via window title,
+ // renderer identity, and the app-menu label passed to registerAppMenu below.
+ app.setName(devInstanceIdentity.appName)
// Why: managed WSL launchers live outside the Windows app bundle, so keep
// their launcher and bridge contract synchronized across app updates.
@@ -2138,6 +2142,7 @@ app.whenReady().then(async () => {
logStartupMilestone('i18n-ready')
registerAppMenu({
+ appMenuLabel: devInstanceIdentity.name,
onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options),
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts
index c36aca6973a..2f3a3d70a97 100644
--- a/src/main/menu/register-app-menu.ts
+++ b/src/main/menu/register-app-menu.ts
@@ -37,6 +37,9 @@ type RegisterAppMenuOptions = {
onToggleAppearance: (key: AppearanceMenuKey) => void
getAppearanceState: () => AppearanceMenuState
getKeybindings?: () => KeybindingOverrides | undefined
+ // Why: the macOS app-menu title. Passed the per-branch dev label since
+ // app.name is now pinned to a stable value for Keychain-key stability.
+ appMenuLabel?: string
}
function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
@@ -130,7 +133,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
// redundant "Orca" entry with roles that don't apply, so we omit it there
// and distribute its items across File / Help instead.
const macAppMenu: Electron.MenuItemConstructorOptions = {
- label: app.name,
+ label: options.appMenuLabel ?? app.name,
submenu: [
{ role: 'about' },
checkForUpdatesItem,
diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts
index 2021dfed224..2b06e85dda4 100644
--- a/src/main/startup/dev-instance-identity.test.ts
+++ b/src/main/startup/dev-instance-identity.test.ts
@@ -5,6 +5,7 @@ describe('dev-instance-identity', () => {
it('keeps packaged identity stable', () => {
expect(getDevInstanceIdentity(false, {})).toMatchObject({
name: 'Orca',
+ appName: 'Orca',
isDev: false,
devLabel: null,
dockBadgeLabel: null,
@@ -12,6 +13,18 @@ describe('dev-instance-identity', () => {
})
})
+ it('pins a stable dev appName across branches so the safeStorage key does not churn', () => {
+ const a = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/a' })
+ const b = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/b' })
+
+ // Per-branch label differs (window title / app menu)...
+ expect(a.name).not.toBe(b.name)
+ // ...but the Keychain-driving appName is identical and distinct from prod.
+ expect(a.appName).toBe('Orca Dev')
+ expect(b.appName).toBe('Orca Dev')
+ expect(a.appName).not.toBe('Orca')
+ })
+
it('derives a readable dev label from worktree and branch env', () => {
const identity = getDevInstanceIdentity(true, {
ORCA_DEV_REPO_ROOT: '/repo/worktrees/dev-indicator',
diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts
index b375e88bba7..6d13541ea1f 100644
--- a/src/main/startup/dev-instance-identity.ts
+++ b/src/main/startup/dev-instance-identity.ts
@@ -8,6 +8,11 @@ const MAX_LABEL_LENGTH = 80
export type DevInstanceIdentity = AppIdentity & {
appUserModelId: string
+ // Why: drives app.setName → the macOS safeStorage Keychain item name
+ // (" Safe Storage"). Kept stable across dev branches (unlike the
+ // per-branch `name`) so every dev instance shares one Keychain key instead of
+ // creating a new one per branch and re-prompting. Distinct from prod's 'Orca'.
+ appName: string
}
function cleanEnvValue(value: string | undefined): string | null {
@@ -50,6 +55,7 @@ export function getDevInstanceIdentity(
if (!isDev) {
return {
name: BASE_APP_NAME,
+ appName: BASE_APP_NAME,
isDev: false,
devLabel: null,
devBranch: null,
@@ -71,6 +77,10 @@ export function getDevInstanceIdentity(
return {
name: dockTitle,
+ // Why: one stable Keychain key ('Orca Dev Safe Storage') for all dev
+ // branches; the per-branch identity still shows via `name` (window title,
+ // app menu, renderer label).
+ appName: `${BASE_APP_NAME} Dev`,
isDev: true,
devLabel,
devBranch: branch,
From a02389cea809239c7d38409e62b957b2dcaf66e1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:40:49 -0700
Subject: [PATCH 08/35] Update code comments section in AGENTS.md
Clarified guidelines for code comments, emphasizing the importance of explaining the 'why' behind non-obvious code.
---
AGENTS.md | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 2c117478e84..1fd213f742e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,7 @@
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Code Comments: Document the "Why", Briefly
-
-When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
-
-Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
-
-The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact:
+When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line.
```ts
// 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller:
From 64dae13bc9b5bfb7d27ae42d8f593913344c064e Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Mon, 20 Jul 2026 01:18:27 -0400
Subject: [PATCH 09/35] fix(sidebar): stop agent 'working' spinner freezing as
a broken ring under reduced motion (#9515)
Under prefers-reduced-motion (Windows 'Animation effects' off), the agent
'working' spinner froze mid-rotation as a partial (3/4) ring, reading as a
broken spinner in the terminal tab and worktree card. Fill the top border so
it becomes a complete static ring, and apply the same treatment to
StatusIndicator so the sidebar dot honors reduced motion consistently instead
of stepping. Mirrors the existing feature-tour-preview-glyphs pattern.
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
src/renderer/src/components/AgentStateDot.test.ts | 3 +++
src/renderer/src/components/AgentStateDot.tsx | 5 ++++-
.../src/components/sidebar/StatusIndicator.test.ts | 4 ++++
src/renderer/src/components/sidebar/StatusIndicator.tsx | 8 ++++++--
4 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts
index 2e17d0423df..11297224433 100644
--- a/src/renderer/src/components/AgentStateDot.test.ts
+++ b/src/renderer/src/components/AgentStateDot.test.ts
@@ -24,6 +24,9 @@ describe('AgentStateDot', () => {
expect(markup).toContain('border-t-transparent')
expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]')
expect(markup).toContain('motion-reduce:animate-none')
+ // Why: under reduced motion the top border is filled so the frozen ring
+ // reads as a complete static marker, not a broken partial spinner.
+ expect(markup).toContain('motion-reduce:border-t-yellow-500')
expect(markup).not.toContain('animate-spin')
})
diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx
index 9a4fe792e13..7543de46fac 100644
--- a/src/renderer/src/components/AgentStateDot.tsx
+++ b/src/renderer/src/components/AgentStateDot.tsx
@@ -78,7 +78,10 @@ export const AgentStateDot = React.memo(function AgentStateDot({
className={cn(
// Why: match the sidebar worktree spinner's stepped cadence so
// long-running visible agents do not keep a full-frame-rate loop.
- 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none',
+ // Why: under reduced motion the spin is disabled, so fill the top
+ // border too — a frozen transparent-top ring reads as a broken
+ // spinner; a complete ring reads as an intentional static marker.
+ 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none motion-reduce:border-t-yellow-500',
inner
)}
/>
diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts
index 5f9061eb937..0179a0bac02 100644
--- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts
+++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts
@@ -23,6 +23,10 @@ describe('StatusIndicator', () => {
expect(classNames).toContain('border-yellow-500')
expect(classNames).toContain('border-t-transparent')
expect(classNames).toContain('[animation:spin_1s_steps(12,end)_infinite]')
+ // Why: under reduced motion the spin is disabled and the top border filled,
+ // so the dot stays a complete static ring rather than freezing mid-rotation.
+ expect(classNames).toContain('motion-reduce:animate-none')
+ expect(classNames).toContain('motion-reduce:border-t-yellow-500')
expect(classNames).not.toContain('animate-spin')
})
diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx
index 34c1491095f..c181e0c297d 100644
--- a/src/renderer/src/components/sidebar/StatusIndicator.tsx
+++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx
@@ -34,8 +34,12 @@ const StatusIndicator = React.memo(function StatusIndicator({
{...rest}
>
{/* Why: a stepped spin preserves the worker-is-running affordance while
- avoiding a full-refresh-rate compositor loop for long agent runs. */}
-
+ avoiding a full-refresh-rate compositor loop for long agent runs.
+ Why: honor reduced motion (Windows "Animation effects" off →
+ prefers-reduced-motion) by stopping the spin and filling the top
+ border, so the dot stays a complete static ring instead of a
+ partial ring frozen mid-rotation that reads as a broken spinner. */}
+
)
}
From 4d25de383e0c294c539b6101281e30a44c772120 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 19 Jul 2026 22:53:32 -0700
Subject: [PATCH 10/35] perf(renderer): stop agent spinners from keeping the
frame pipeline awake (one shared clock, hidden-window stop) (#9380)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* perf(renderer): drive agent working-spinners from one shared clock
Per-element infinite CSS spin animations kept Chromium's frame pipeline
awake for the whole agent run — measured live (interleaved A/B/A/B,
renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0
with the clock, one working agent, and the CSS cost scales per element
while the clock is one flat timer for N spinners.
The clock ticks 12 steps/s at 30° — frame-for-frame identical to the
retired animation — writes style.transform on registered elements, and
stops on document hidden (native visibilitychange post-#9395), under
prefers-reduced-motion (static ring keeps the filled top border from
#9515), and when the last spinner unmounts. The stale-visibility latch
keeps it spinning when proven user input contradicts the occlusion
tracker.
Co-authored-by: Orca
* fix(github): restore explicit space after Filter fallback in PRFilterSections
Pre-existing failure on main: i18n-jsx-spacing-guard requires {' '} after
the 'Filter' translate fallback, but the file had a bare JSX space (which
oxfmt collapses). Wrap the subject in a span so the explicit {' '} survives
formatting, matching the pattern in the other guarded files.
Co-authored-by: Orca
---------
Co-authored-by: Orca
---
.../src/components/AgentStateDot.test.ts | 11 +-
src/renderer/src/components/AgentStateDot.tsx | 13 +-
.../src/components/AgentWorkingSpinner.tsx | 23 +++
.../components/github/PRFilterSections.tsx | 3 +-
.../sidebar/StatusIndicator.test.ts | 22 +--
.../components/sidebar/StatusIndicator.tsx | 9 +-
.../tab-bar/TerminalTabLeadingIcon.test.tsx | 2 +-
.../src/lib/agent-spinner-clock.test.ts | 141 ++++++++++++++++++
src/renderer/src/lib/agent-spinner-clock.ts | 128 ++++++++++++++++
9 files changed, 318 insertions(+), 34 deletions(-)
create mode 100644 src/renderer/src/components/AgentWorkingSpinner.tsx
create mode 100644 src/renderer/src/lib/agent-spinner-clock.test.ts
create mode 100644 src/renderer/src/lib/agent-spinner-clock.ts
diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts
index 11297224433..40c2bea60f3 100644
--- a/src/renderer/src/components/AgentStateDot.test.ts
+++ b/src/renderer/src/components/AgentStateDot.test.ts
@@ -22,12 +22,15 @@ describe('AgentStateDot', () => {
expect(markup).toContain('border-yellow-500')
expect(markup).toContain('border-t-transparent')
- expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]')
- expect(markup).toContain('motion-reduce:animate-none')
- // Why: under reduced motion the top border is filled so the frozen ring
- // reads as a complete static marker, not a broken partial spinner.
+ // Why: rotation comes from the shared agent-spinner clock (which also
+ // honors prefers-reduced-motion), not a per-element CSS animation that
+ // would keep the compositor awake.
+ expect(markup).toContain('data-agent-spinner')
+ // Why: under reduced motion the top border is filled so the static ring
+ // reads as a complete marker, not a broken partial spinner (#9515).
expect(markup).toContain('motion-reduce:border-t-yellow-500')
expect(markup).not.toContain('animate-spin')
+ expect(markup).not.toContain('animation:spin')
})
it('renders done as an emerald check icon', () => {
diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx
index 7543de46fac..a5c50dad1fe 100644
--- a/src/renderer/src/components/AgentStateDot.tsx
+++ b/src/renderer/src/components/AgentStateDot.tsx
@@ -1,6 +1,7 @@
import React from 'react'
import { CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
+import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
// Why: shared state-indicator primitive so the dashboard and the sidebar's
// agent hover share a single state vocabulary. Most states render as a dot;
@@ -74,17 +75,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
-
+
)
}
diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx
new file mode 100644
index 00000000000..7e3d3302a7e
--- /dev/null
+++ b/src/renderer/src/components/AgentWorkingSpinner.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import { cn } from '@/lib/utils'
+import { agentSpinnerRef } from '@/lib/agent-spinner-clock'
+
+// Why: the working-state ring must not carry its own infinite CSS animation —
+// that keeps the compositor awake per element for the whole agent run. The
+// shared agent-spinner clock rotates every mounted ring in phase and stops
+// cleanly when nothing can be seen. Callers size it via className (size-2 etc.).
+export function AgentWorkingSpinner({ className }: { className?: string }): React.JSX.Element {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx
index 6f65eee8e55..d704ea12754 100644
--- a/src/renderer/src/components/github/PRFilterSections.tsx
+++ b/src/renderer/src/components/github/PRFilterSections.tsx
@@ -208,7 +208,8 @@ export function SectionMenu({
return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject}
+ {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')}{' '}
+ {subject}
{rows.map((row) => (