Merge remote-tracking branch 'origin/main' into aug23-sta5269-pty-owner-identity

# Conflicts:
#	src/main/daemon/daemon-pty-adapter.ts
#	src/main/daemon/daemon-server.ts
This commit is contained in:
Jinwoo-H
2026-08-25 00:45:55 -04:00
181 changed files with 18180 additions and 14024 deletions
-8
View File
@@ -21,21 +21,16 @@ inline src/main/codex-accounts/service.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/memory/collector.ts
@@ -87,9 +82,6 @@ inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+35 -5
View File
@@ -8,13 +8,17 @@
* the host Node floor at 18 instead of 22.5+.
*/
import { build } from 'esbuild'
import { mkdirSync, rmSync } from 'node:fs'
import { chmodSync, copyFileSync, mkdirSync, rmSync } from 'node:fs'
import { arch, platform } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
@@ -48,6 +52,10 @@ const externalNativeAddons = {
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
const result = await build({
entryPoints: [ENTRY],
@@ -78,12 +86,34 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) {
}
}
}
const sqliteImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'node:sqlite') {
sqliteImporters.add(file)
}
}
}
const graphErrors = []
if (electronImporters.size > 0) {
console.error(
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
graphErrors.push(
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (sqliteImporters.size > 0) {
graphErrors.push(
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (graphErrors.length > 0) {
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
@@ -91,6 +121,6 @@ ${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
process.exitCode = 1
} else {
console.log(
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
)
}
@@ -18,11 +18,14 @@
* - a paired client can list worktrees and create a terminal,
* - a command run in that terminal produces its output,
* - the server exits when asked.
* Optional `--browser` assertions:
* - create, navigate, evaluate, and screenshot through the selected host provider.
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
@@ -274,6 +277,50 @@ async function main() {
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
}
log(`server resolves ${shown.id}`)
if (process.argv.includes('--browser')) {
const status = orca(pairingCode, ['status'])
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
throw new Error(
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
)
}
const fixturePath = join(userDataDir, 'browser-smoke.html')
writeFileSync(
fixturePath,
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
)
const browserPageId = orca(pairingCode, [
'tab',
'create',
'--worktree',
created.id,
'--url',
'about:blank'
])?.browserPageId
if (!browserPageId) {
throw new Error('browser.tabCreate returned no browser page id')
}
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
const targetUrl = pathToFileURL(fixturePath).href
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
}
const evaluated = orca(pairingCode, [
'eval',
...targetFlags,
'--expression',
'document.querySelector("main")?.textContent'
])
if (evaluated?.result !== 'browser-ready') {
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
}
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
throw new Error('browser.screenshot returned no PNG data')
}
log('browser navigate/evaluate/screenshot round trip OK')
}
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
@@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => {
)
expect(socket.close).toHaveBeenCalledOnce()
})
it('keeps a typed close code when transport error precedes close', () => {
const socket = new ThrowingSocket()
const onError = vi.fn()
new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
socket.onclose?.({ code: 4409 })
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' }))
})
it('classifies an opaque close after transport error as 1006', () => {
const socket = new ThrowingSocket()
const onError = vi.fn()
new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
socket.onclose?.({ code: 0 })
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
})
it('bounds an error when the platform never emits close', async () => {
vi.useFakeTimers()
try {
const socket = new ThrowingSocket()
const onError = vi.fn()
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
await vi.advanceTimersByTimeAsync(250)
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
link.close()
} finally {
vi.useRealTimers()
}
})
it('cancels the missing-close timer when explicitly closed', async () => {
vi.useFakeTimers()
try {
const socket = new ThrowingSocket()
const onError = vi.fn()
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
link.close()
await vi.advanceTimersByTimeAsync(250)
expect(onError).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+28 -2
View File
@@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
// Native WebSockets normally emit close immediately after error; bound the
// missing-close case so a dead socket cannot leave recovery pending forever.
const RELAY_ERROR_CLOSE_GRACE_MS = 250
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
@@ -32,6 +36,7 @@ export class MobileRelayE2eeLink {
private readonly channel: MobileE2EEV2PhysicalChannel
private outerReady = false
private closed = false
private transportErrorTimer: ReturnType<typeof setTimeout> | null = null
private inboundChain: Promise<void> = Promise.resolve()
constructor(options: MobileRelayE2eeLinkOptions) {
@@ -70,6 +75,10 @@ export class MobileRelayE2eeLink {
return
}
this.closed = true
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.channel.dispose()
this.socket.close()
}
@@ -103,8 +112,21 @@ export class MobileRelayE2eeLink {
})
.catch((error: unknown) => this.fail(asError(error)))
}
this.socket.onerror = () => this.fail(new Error('relay transport error'))
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
// `error` is often delivered just before `close`; wait for close so a
// typed relay code is not replaced by a generic transport error.
this.socket.onerror = () => {
this.transportErrorTimer ??= setTimeout(() => {
this.transportErrorTimer = null
this.fail(new RelayOuterError(1006))
}, RELAY_ERROR_CLOSE_GRACE_MS)
}
this.socket.onclose = (event) => {
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.fail(new RelayOuterError(event.code || 1006))
}
}
private acceptHello(raw: unknown): void {
@@ -137,6 +159,10 @@ export class MobileRelayE2eeLink {
return
}
this.closed = true
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.channel.dispose()
this.options.onError(error)
this.socket.close()
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import {
RelayDirectorMoveNotNewerError,
resolvePairingInviteThroughDirector
} from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
@@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => {
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
it('rejects malformed moves with untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
@@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => {
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
await expect(resolving).rejects.toThrow(/invalid relay director move/)
})
it('describes a same-epoch move without accepting it', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
})
})
const error = await resolving.catch((value: unknown) => value)
expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError)
expect(error).toMatchObject({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
})
})
@@ -1,6 +1,27 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export class RelayDirectorMoveNotNewerError extends Error {
readonly cellUrl: string
readonly assignmentEpoch: number
readonly currentCellUrl: string
readonly currentAssignmentEpoch: number
constructor(args: {
cellUrl: string
assignmentEpoch: number
currentCellUrl: string
currentAssignmentEpoch: number
}) {
super('relay director move was not strictly newer')
this.name = 'RelayDirectorMoveNotNewerError'
this.cellUrl = args.cellUrl
this.assignmentEpoch = args.assignmentEpoch
this.currentCellUrl = args.currentCellUrl
this.currentAssignmentEpoch = args.currentAssignmentEpoch
}
}
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
@@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: {
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
if (!moved.success) {
finish(new Error('invalid relay director move'))
return
}
if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(
new RelayDirectorMoveNotNewerError({
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch,
currentCellUrl: args.relay.cellUrl,
currentAssignmentEpoch: args.relay.assignmentEpoch
})
)
return
}
settled = true
@@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => {
expect(fakes.start).not.toHaveBeenCalled()
})
it('keeps the typed close code when transport error precedes close', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.onerror?.()
socket.onclose?.({ code: 4409 })
await expect(status).rejects.toEqual(new RelayOuterError(4409))
})
it('classifies an opaque close after transport error as 1006', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.onerror?.()
socket.onclose?.({ code: 0 })
await expect(status).rejects.toEqual(new RelayOuterError(1006))
})
it('settles after an error when the platform never emits close', async () => {
vi.useFakeTimers()
try {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006))
socket.onerror?.()
await vi.advanceTimersByTimeAsync(250)
await rejected
} finally {
vi.useRealTimers()
}
})
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
const socket = new FakeSocket()
const entries: ConnectionLogEntry[] = []
@@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayOuterError } from './mobile-relay-e2ee-link'
const RELAY_ERROR_CLOSE_GRACE_MS = 250
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
@@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: {
let requestCounter = 0
let closed = false
let intentionallyClosed = false
let transportErrorTimer: ReturnType<typeof setTimeout> | null = null
let outerReady = false
let authenticated = false
let resolveAuthenticated!: () => void
@@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: {
})
.catch((error: unknown) => fail(asError(error)))
}
socket.onerror = () => fail(new Error('relay transport error'))
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
// WebSocket implementations commonly emit `error` immediately before
// `close`; the bounded fallback represents an opaque 1006 close.
socket.onerror = () => {
transportErrorTimer ??= setTimeout(() => {
transportErrorTimer = null
fail(new RelayOuterError(1006))
}, RELAY_ERROR_CLOSE_GRACE_MS)
}
socket.onclose = (event) => {
if (transportErrorTimer) {
clearTimeout(transportErrorTimer)
transportErrorTimer = null
}
fail(new RelayOuterError(event.code || 1006))
}
function acceptRelayHello(raw: unknown): void {
if (typeof raw !== 'string') {
@@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: {
return
}
closed = true
if (transportErrorTimer) {
clearTimeout(transportErrorTimer)
transportErrorTimer = null
}
if (intentionallyClosed) {
log('info', 'Relay: pairing socket closed', cellHost)
} else {
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayOuterError } from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import type { ConnectionLogEntry } from './types'
@@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => {
expect(stale.close).toHaveBeenCalledOnce()
})
it('retries the authoritative cell when the director confirms the same assignment', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const target = client(Promise.resolve(success()))
const persistMove = vi.fn()
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch)
return connects++ === 0 ? stale : target
},
resolveDirector: async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
},
persistMove,
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(connects).toBe(2)
expect(persistMove).not.toHaveBeenCalled()
})
it('keeps recovery bounded when the authoritative-cell constructor throws', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const target = client(Promise.resolve(success()))
const entries: ConnectionLogEntry[] = []
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
connects++
if (connects === 1) {
return stale
}
if (connects === 2) {
throw new Error('relay constructor failed')
}
return target
},
resolveDirector: async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
},
persistMove: vi.fn(),
now: () => 1,
random: () => 0,
sleep: async () => {},
onLog: (entry) => entries.push(entry)
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(connects).toBe(3)
expect(entries).toContainEqual(
expect.objectContaining({
level: 'warn',
message: 'Relay: recovery attempt 1 failed',
detail: 'Error: relay constructor failed'
})
)
})
it('does not retry a same-epoch move to a different cell', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const resolveDirector = vi.fn(async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
connects++
return stale
},
resolveDirector,
persistMove: vi.fn(),
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
expect(resolveDirector).toHaveBeenCalledTimes(3)
expect(connects).toBe(1)
})
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
const offline = client(Promise.reject(new RelayOuterError(4404)))
const resolveDirector = vi.fn()
@@ -1,6 +1,7 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogSink } from './types'
@@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: {
return await client.sendRequest(method, params)
} catch (error) {
lastError = error
if (isCurrentAssignmentMove(error, relay)) {
log(
'info',
'Relay: director kept current assignment',
redactSocketEndpoint(relay.cellUrl)
)
client.close()
await backOff(attempt)
if (closed) {
throw new Error('relay pairing client closed')
}
try {
client = args.connect(relay, args.onLog)
return await client.sendRequest(method, params)
} catch (retryError) {
lastError = retryError
log(
'warn',
`Relay: recovery attempt ${attempt + 1} failed`,
pairingRelayErrorDetail(retryError)
)
if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) {
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
throw retryError
}
await backOff(attempt)
continue
}
}
log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
@@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: {
}
}
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
return (
error instanceof RelayDirectorMoveNotNewerError &&
error.assignmentEpoch === relay.assignmentEpoch &&
error.cellUrl === relay.cellUrl &&
error.currentAssignmentEpoch === relay.assignmentEpoch &&
error.currentCellUrl === relay.cellUrl
)
}
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
+2 -1
View File
@@ -172,7 +172,8 @@ export class RuntimeClient {
...(response.result.remoteUpdateSupport
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
: {}),
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}),
...(response.result.degradations ? { degradations: response.result.degradations } : {})
},
graph: {
state: graphState
+10 -2
View File
@@ -47,7 +47,14 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0
liveTabCount: 0,
degradations: [
{
code: 'browser_unavailable',
capability: 'browser.headless.v1',
message: 'Browser automation is unavailable.'
}
]
},
_meta: { runtimeId: 'runtime-legacy' }
})}\n`
@@ -72,7 +79,8 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
expect(status.result.runtime).toMatchObject({
reachable: true,
runtimeId: 'runtime-legacy',
state: 'ready'
state: 'ready',
degradations: [expect.objectContaining({ code: 'browser_unavailable' })]
})
})
})
+2 -1
View File
@@ -56,7 +56,8 @@ export async function getCliStatus(
...(response.result.remoteUpdateSupport
? { remoteUpdateSupport: response.result.remoteUpdateSupport }
: {}),
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {})
...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}),
...(response.result.degradations ? { degradations: response.result.degradations } : {})
},
graph: {
state: graphState
+14 -3
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WebSocketServer } from 'ws'
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
import type { RuntimeStatus } from '../../shared/runtime-types'
import {
decrypt,
deriveSharedKey,
@@ -90,7 +91,14 @@ describe('CLI remote WebSocket transport', () => {
automatic: false,
reason: 'manual-service-update-required'
},
capabilities: ['updater.remote-control.v1']
capabilities: ['updater.remote-control.v1'],
degradations: [
{
code: 'browser_unavailable',
capability: 'browser.headless.v1',
message: 'Browser automation is unavailable.'
}
]
})
servers.push(runtime)
const offer: PairingOffer = {
@@ -113,7 +121,8 @@ describe('CLI remote WebSocket transport', () => {
expect(status.result.runtime).toMatchObject({
appVersion: '1.5.0',
remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' },
capabilities: ['updater.remote-control.v1']
capabilities: ['updater.remote-control.v1'],
degradations: [expect.objectContaining({ code: 'browser_unavailable' })]
})
})
@@ -243,6 +252,7 @@ async function startTestRuntime(
reason: 'manual-service-update-required'
}
capabilities?: string[]
degradations?: RuntimeStatus['degradations']
} = {}
): Promise<TestRuntime> {
const serverKeyPair = generateKeyPair()
@@ -314,7 +324,8 @@ async function startTestRuntime(
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
appVersion: statusOverrides.appVersion,
remoteUpdateSupport: statusOverrides.remoteUpdateSupport,
capabilities: statusOverrides.capabilities
capabilities: statusOverrides.capabilities,
degradations: statusOverrides.degradations
},
_meta: { runtimeId }
}
@@ -0,0 +1,249 @@
import type { Socket } from 'node:net'
import { StringDecoder } from 'node:string_decoder'
import type { DaemonFileLog } from './daemon-file-log'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { createNdjsonParser, encodeNdjson } from './ndjson'
import type { DaemonRequest, HelloMessage } from './types'
export type ConnectedDaemonClient = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
type DaemonClientConnectionOptions = {
token: string
protocolVersion: number
identity: {
launchNonce: string | null
startedAtMs: number | null
entryPath: string | null
appVersion: string | null
spawnerExecPath: string | null
}
log: DaemonFileLog
streamDataBatcher: DaemonStreamDataBatcher
isAcceptingWork: () => boolean
onTransportChanged: () => void
onConnectionAccepted: () => void
onAuthenticatedPair: () => void
onLastAuthenticatedClientDisconnected: () => void
onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void
onControlReplaced: (clientId: string) => void
onClientDisconnected: (clientId: string) => void
onStreamDisconnected: (clientId: string) => void
}
export class DaemonClientConnections {
private readonly clients = new Map<string, ConnectedDaemonClient>()
private readonly transportSockets = new Set<Socket>()
constructor(private readonly options: DaemonClientConnectionOptions) {}
accept(socket: Socket): void {
this.options.onConnectionAccepted()
this.transportSockets.add(socket)
socket.once('close', () => {
this.transportSockets.delete(socket)
this.options.onTransportChanged()
})
socket.on('error', () => socket.destroy())
if (!this.options.isAcceptingWork()) {
socket.end(
encodeNdjson({
type: 'hello',
ok: false,
error: 'Daemon temporarily unavailable; reconnect',
retryable: true
})
)
return
}
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(message) => this.handleFirstMessage(socket, message),
() => socket.destroy()
)
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
}
get(clientId: string): ConnectedDaemonClient | undefined {
return this.clients.get(clientId)
}
values(): IterableIterator<ConnectedDaemonClient> {
return this.clients.values()
}
get size(): number {
return this.clients.size
}
get transportCount(): number {
return this.transportSockets.size
}
hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean {
return [...this.transportSockets].every(
(transport) => transport === client.controlSocket || transport === client.streamSocket
)
}
dispose(): void {
for (const client of this.clients.values()) {
client.controlSocket.destroy()
client.streamSocket?.destroy()
}
this.clients.clear()
for (const socket of this.transportSockets) {
socket.destroy()
}
this.transportSockets.clear()
}
private handleFirstMessage(socket: Socket, message: unknown): void {
const hello = message as HelloMessage
if (hello.type !== 'hello') {
this.options.log.log('client-hello-rejected', { reason: 'expected-hello' })
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' }))
socket.destroy()
return
}
if (hello.version !== this.options.protocolVersion) {
this.options.log.log('client-hello-rejected', {
reason: 'protocol-mismatch',
clientVersion: hello.version
})
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' }))
socket.destroy()
return
}
if (hello.token !== this.options.token) {
this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role })
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' }))
socket.destroy()
return
}
if (hello.role !== 'control' && hello.role !== 'stream') {
this.options.log.log('client-hello-rejected', {
reason: 'invalid-role',
role: hello.role
})
socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' }))
return
}
this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
const identity = this.options.identity
socket.write(
encodeNdjson({
type: 'hello',
ok: true,
...(identity.launchNonce && identity.startedAtMs
? {
daemonIdentity: {
pid: process.pid,
startedAtMs: identity.startedAtMs,
launchNonce: identity.launchNonce,
...(identity.entryPath ? { entryPath: identity.entryPath } : {}),
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {})
}
}
: {})
})
)
if (hello.role === 'control') {
this.installControlSocket(socket, hello.clientId)
return
}
if (hello.role === 'stream') {
const client = this.clients.get(hello.clientId)
if (!client) {
socket.destroy()
return
}
this.installStreamSocket(socket, client)
client.authenticatedPairEstablished = true
this.options.onAuthenticatedPair()
return
}
// Parsed wire data is not made safe by the HelloMessage assertion above.
socket.destroy()
}
private installControlSocket(socket: Socket, clientId: string): void {
const previous = this.clients.get(clientId)
const client: ConnectedDaemonClient = {
clientId,
controlSocket: socket,
streamSocket: null,
authenticatedPairEstablished: false
}
this.clients.set(clientId, client)
this.setupControlParser(socket, clientId)
if (!previous) {
return
}
this.options.onControlReplaced(clientId)
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
previous.streamSocket?.destroy()
previous.controlSocket.destroy()
}
private setupControlParser(socket: Socket, clientId: string): void {
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest),
() => {}
)
socket.removeAllListeners('data')
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
socket.on('close', () => {
const client = this.clients.get(clientId)
if (client?.controlSocket !== socket) {
return
}
this.options.onClientDisconnected(clientId)
const wasFullyAuthenticated = client.authenticatedPairEstablished
client.streamSocket?.destroy()
this.clients.delete(clientId)
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
this.options.onTransportChanged()
})
}
private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
if (
wasFullyAuthenticated &&
![...this.clients.values()].some((client) => client.authenticatedPairEstablished) &&
this.options.isAcceptingWork()
) {
this.options.onLastAuthenticatedClientDisconnected()
}
}
private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void {
const previous = client.streamSocket
socket.removeAllListeners('data')
client.streamSocket = socket
socket.on('drain', () => this.options.streamDataBatcher.flush(client.clientId))
const cleanup = (): void => {
socket.removeListener('close', cleanup)
socket.removeListener('error', cleanup)
if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) {
return
}
this.options.onStreamDisconnected(client.clientId)
client.streamSocket = null
}
socket.on('close', cleanup)
socket.on('error', cleanup)
if (previous && previous !== socket) {
previous.destroy()
}
}
}
@@ -2,7 +2,7 @@ import type { Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DaemonPendingRequests } from './daemon-client-pending-requests'
import { requestDaemonRpc } from './daemon-client-rpc-request'
import { isDaemonGoneError } from './daemon-pty-adapter'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import {
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
DaemonConnectionLostError,
+40
View File
@@ -0,0 +1,40 @@
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types'
/**
* Narrow on purpose: only the daemon's own reply for a request type it does not implement.
* A transient failure must stay unproven rather than be mistaken for a missing capability.
* The server throws `Unknown request type: <type>`; the client rejects with that text, which
* `addNodePtyRecoveryHint` only ever prepends to.
*/
export function isUnknownRequestTypeError(err: unknown): boolean {
return err instanceof Error && err.message.includes('Unknown request type')
}
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
export function isDaemonGoneError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') {
return true
}
const msg = err.message
return (
msg === 'Connection lost' ||
msg === 'Not connected' ||
msg === 'Hello response timed out' ||
msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE ||
msg === DAEMON_ENDPOINT_LOST_MESSAGE
)
}
export function isMissingWindowsNamedPipeError(err: unknown): boolean {
if (process.platform !== 'win32' || !(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'connect'
}
@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
export function sameEndpointIdentity(
left: DaemonEndpointIdentity,
right: DaemonEndpointIdentity
): boolean {
return (
left.pid === right.pid &&
left.startedAtMs === right.startedAtMs &&
left.launchNonce === right.launchNonce
)
}
export function exactDaemonIncarnationForPidRecord(
identity: DaemonEndpointIdentity,
pidRecord: ParsedDaemonPid | null
): ExactDaemonIncarnation {
return {
identity: { ...identity },
...(process.platform === 'linux' &&
pidRecord?.pid === identity.pid &&
pidRecord.startedAtMs === identity.startedAtMs &&
pidRecord.launchNonce === identity.launchNonce &&
pidRecord.linuxStartTicks &&
pidRecord.bootId
? {
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: {})
}
}
export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null {
if (!pidPath) {
return null
}
try {
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
} catch {
return null
}
}
@@ -0,0 +1,173 @@
import { chmodSync, unlinkSync, writeFileSync } from 'node:fs'
import type { DaemonFileLog } from './daemon-file-log'
import {
DaemonEndpointUnavailableError,
getDaemonSocketBindPath,
publishDaemonEndpoint,
readDaemonEndpointOwnershipState,
type DaemonEndpointOwnershipState,
type DaemonSocketIdentity
} from './daemon-endpoint-ownership'
import { probeSocketConnect } from './daemon-endpoint-probe'
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
type DaemonEndpointLifecycleOptions = {
socketPath: string
tokenPath: string
pidPath: string | null
launchNonce: string | null
token: string
publishEndpointOwnership: () => void
log: DaemonFileLog
isServing: () => boolean
onOwnershipLost: () => void
}
export class DaemonEndpointLifecycle {
private static readonly OWNERSHIP_POLL_MS = 30 * 1000
private static readonly LOSS_CONFIRMATIONS = 2
private ownedSocketIdentity: DaemonSocketIdentity | null = null
private ownershipTimer: ReturnType<typeof setInterval> | null = null
private ownershipLossStreak = 0
private ownershipLost = false
constructor(private readonly options: DaemonEndpointLifecycleOptions) {}
bindPath(): string {
return process.platform === 'win32'
? this.options.socketPath
: getDaemonSocketBindPath(this.options.socketPath)
}
secureBindPath(bindPath: string): void {
try {
chmodSync(bindPath, 0o600)
} catch {
// Best-effort on platforms that support it.
}
}
abandonBindPath(bindPath: string): void {
if (process.platform === 'win32') {
return
}
try {
unlinkSync(bindPath)
} catch {
// Already consumed by publication, or never created.
}
}
async publish(bindPath: string): Promise<void> {
const outcome = await publishDaemonEndpoint(
bindPath,
this.options.socketPath,
probeSocketConnect
)
if (outcome.status !== 'published') {
this.options.log.log('endpoint-publish-declined', { reason: outcome.status })
console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`)
throw new DaemonEndpointUnavailableError(outcome.status)
}
this.ownedSocketIdentity = outcome.identity
let publishedOwnership = false
try {
this.options.publishEndpointOwnership()
publishedOwnership = true
writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 })
} catch (error) {
if (publishedOwnership && this.options.pidPath && this.options.launchNonce) {
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
}
this.ownedSocketIdentity = null
throw error
}
}
retireUnstarted(): void {
this.stopOwnershipWatch()
this.unlinkOwnedArtifacts()
}
unlinkOwnedArtifacts(): void {
unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token)
if (this.options.pidPath && this.options.launchNonce) {
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
}
// The canonical endpoint stays: removing it could delete a replacement's name.
this.ownedSocketIdentity = null
}
stopOwnershipWatch(): void {
if (this.ownershipTimer === null) {
return
}
clearInterval(this.ownershipTimer)
this.ownershipTimer = null
}
hasLostOwnership(): boolean {
if (this.ownershipLost) {
return true
}
return this.observeOwnership() === 'lost'
}
get lost(): boolean {
return this.ownershipLost
}
requestRetirementForLoss(): void {
const alreadyLost = this.ownershipLost
this.ownershipLost = true
this.ownedSocketIdentity = null
if (!alreadyLost) {
this.options.log.log('endpoint-ownership-lost', {
socketPath: this.options.socketPath
})
console.warn(
'[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end'
)
}
this.stopOwnershipWatch()
this.options.onOwnershipLost()
}
startOwnershipWatch(): void {
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
return
}
this.ownershipTimer = setInterval(
() => this.checkOwnership(),
DaemonEndpointLifecycle.OWNERSHIP_POLL_MS
)
this.ownershipTimer.unref()
}
private checkOwnership(): void {
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
return
}
if (this.observeOwnership() !== 'lost') {
return
}
this.ownershipLossStreak++
if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) {
this.requestRetirementForLoss()
}
}
private observeOwnership(): DaemonEndpointOwnershipState {
if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) {
return 'indeterminate'
}
const state = readDaemonEndpointOwnershipState(
this.options.socketPath,
this.ownedSocketIdentity
)
if (state !== 'lost') {
this.ownershipLossStreak = 0
}
return state
}
}
@@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => {
await server.start()
const daemon = server as unknown as {
checkEndpointOwnership: () => void
retirementRequested: boolean
endpoint: { checkOwnership: () => void }
lifecycle: { retirementRequested: boolean }
}
// An inconclusive or matching probe must never retire a healthy daemon, however often it runs.
daemon.checkEndpointOwnership()
daemon.checkEndpointOwnership()
expect(daemon.retirementRequested).toBe(false)
daemon.endpoint.checkOwnership()
daemon.endpoint.checkOwnership()
expect(daemon.lifecycle.retirementRequested).toBe(false)
// Another daemon takes the endpoint name.
unlinkSync(socketPath)
@@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => {
try {
// A single observation can land inside a replacement's unlink-then-link gap, so the
// first one must not retire anything.
daemon.checkEndpointOwnership()
expect(daemon.retirementRequested).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.lifecycle.retirementRequested).toBe(false)
daemon.checkEndpointOwnership()
daemon.endpoint.checkOwnership()
// Why: retirement drains rather than kills — an orphaned daemon stops being a
// permanent unreachable host without tearing live sessions out from under the user.
expect(daemon.retirementRequested).toBe(true)
expect(daemon.lifecycle.retirementRequested).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
try {
@@ -0,0 +1,13 @@
import type { HistoryRecoveryContext } from './daemon-pty-runtime-state'
import type { HistoryRecoveryFreeze } from './history-manager'
// Taking and clearing together prevents any recovery branch from retaining a consumed freeze.
export function takeHistoryRecoveryFreeze(
historyRecovery: HistoryRecoveryContext,
sessionId: string
): HistoryRecoveryFreeze | undefined {
const freeze =
historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined
historyRecovery.freeze = null
return freeze
}
+48 -56
View File
@@ -58,6 +58,22 @@ class ManualIdleClock {
return this.timers.size
}
}
type DaemonServerInternals = {
connections: {
clients: Map<string, { controlSocket: Socket }>
accept(socket: Socket): void
}
admission: { createOrAttachInFlight: number }
lifecycle: {
retirementRequested: boolean
state: string
beginIdleShutdown(): void
}
requestRouter: {
route(clientId: string, request: unknown): Promise<unknown>
}
host: { dispose: () => Promise<void> }
}
function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } {
let onExit: ((code: number) => void) | null = null
@@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const daemon = server as unknown as DaemonServerInternals
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
@@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const daemon = server as unknown as DaemonServerInternals
const controlSocket = [...daemon.connections.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'])
@@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as { createOrAttachInFlight: number }
daemon.createOrAttachInFlight = 1
const daemon = server as unknown as DaemonServerInternals
daemon.admission.createOrAttachInFlight = 1
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
@@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => {
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)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
const second = new DaemonClient({ socketPath, tokenPath })
await second.ensureConnected()
await waitFor(() => !daemon.retirementRequested)
await waitFor(() => !daemon.lifecycle.retirementRequested)
subprocess.exit(0)
expect(onIdleShutdown).not.toHaveBeenCalled()
@@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as { clients: Map<string, unknown> }
await waitFor(() => daemon.clients.has('startup-health-control'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.clients.has('startup-health-control'))
clock.advanceBy(1_000)
healthControl.destroy()
@@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('startup-control-create'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.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(daemon.lifecycle.retirementRequested).toBe(false)
expect(clock.pendingCount).toBe(0)
clock.advanceBy(1_000)
@@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('control-only-overlap'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.clients.has('control-only-overlap'))
paired.disconnect()
await waitFor(() => daemon.retirementRequested)
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(clock.pendingCount).toBe(0)
const response = await requestOnRawSocket(incomplete, {
id: 'overlap-control-create',
@@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => {
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
})
expect(response.error).toContain('connection is incomplete')
expect(daemon.retirementRequested).toBe(true)
expect(daemon.lifecycle.retirementRequested).toBe(true)
incomplete.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
@@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
retirementRequested: boolean
}
await waitFor(() => daemon.retirementRequested)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(clock.pendingCount).toBe(0)
replacementControl.destroy()
@@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => {
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)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
expect(clock.pendingCount).toBe(0)
@@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => {
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<unknown>
}
daemon.idleShutdownState = 'idle-shutdown-pending'
const daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.state = 'idle-shutdown-pending'
await expect(
daemon.routeRequest('late-client', {
daemon.requestRouter.route('late-client', {
id: 'late-create',
type: 'createOrAttach',
payload: { sessionId: 'late', cols: 80, rows: 24 }
@@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => {
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
const daemon = server as unknown as DaemonServerInternals
daemon.admission.createOrAttachInFlight = 1
daemon.beginIdleShutdown()
daemon.lifecycle.beginIdleShutdown()
expect(daemon.idleShutdownState).toBe('running')
expect(daemon.lifecycle.state).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 daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.state = 'idle-shutdown-pending'
const socket = new EventEmitter() as Socket
socket.end = vi.fn() as unknown as Socket['end']
daemon.handleConnection(socket)
daemon.connections.accept(socket)
const payload = vi.mocked(socket.end).mock.calls[0]?.[0]
expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true })
+2 -1
View File
@@ -18,7 +18,8 @@ import {
type DaemonProcessHandle
} from './daemon-spawner'
import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership'
import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonClient } from './client'
import {
@@ -0,0 +1,19 @@
export function removeDaemonListener<T>(listeners: T[], listener: T): void {
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}
export function notifyDaemonAuditListeners<T>(
listeners: readonly ((value: T) => void)[],
value: T
): void {
for (const listener of listeners.slice()) {
try {
listener(value)
} catch {
// Audit observers cannot affect daemon operations.
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { DaemonServer, type DaemonServerOptions } from './daemon-server'
import { DaemonServer } from './daemon-server'
import type { DaemonServerOptions } from './daemon-server-options'
import type { DaemonFileLog } from './daemon-file-log'
export type DaemonStartOptions = {
@@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types'
import type { SubprocessHandle } from './session-subprocess-handle'
type DaemonServerPrivate = {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
clients: Map<string, { streamSocket: Socket | null }>
preparations: { pending: Map<string, Set<unknown>> }
connections: { clients: Map<string, { streamSocket: Socket | null }> }
}
function createMockSubprocess(): SubprocessHandle {
@@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => {
await replacement.ensureConnected()
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).not.toHaveBeenCalled()
@@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => {
disconnected.stream.destroy()
await vi.waitFor(() =>
expect(
(server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket
(server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId)
?.streamSocket
).toBeNull()
)
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).toHaveBeenCalledOnce()
@@ -2,7 +2,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtySessionControl } from './daemon-pty-session-control'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import type { GetSnapshotResult } from './types'
import type { PtyProviderBufferSnapshot } from '../providers/types'
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
async getBufferSnapshot(
id: string,
opts: { scrollbackRows?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
if (!this.supportsAuthoritativeBufferSnapshots) {
return null
}
try {
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
sessionId: id,
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
const restored =
this.historyManager &&
this.historyReader &&
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
: snapshot
return this.toProviderBufferSnapshot(restored)
} catch {
return null
}
}
protected toProviderBufferSnapshot(
snapshot: NonNullable<GetSnapshotResult['snapshot']>
): PtyProviderBufferSnapshot | null {
if (typeof snapshot.outputSequence !== 'number') {
return null
}
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: snapshot.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
alternateScreen: snapshot.modes.alternateScreen,
// Why known `0` is carried too: it proves the app negotiated nothing at
// this boundary, which is a different fact from a source that cannot say.
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {})
}
}
protected async overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyManager || !this.historyReader) {
return liveSnapshot
}
// Why turn the caller away instead of queueing: this session already has a
// compact in flight whose result a third one would only duplicate, and an
// unbounded queue is how a stalled history filesystem grows without limit.
if (this.checkpointQueue.isSaturated(sessionId)) {
return liveSnapshot
}
// Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts.
// Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work.
if (!this.tryAdmitNonFinalCheckpoint(sessionId)) {
return liveSnapshot
}
// Why per session with a deadline: a reattach is a user click, so it must wait
// on this session's own compact and nothing else. A blown deadline does not
// cancel that compact — it keeps running and still commits — so the fallback
// costs restore depth for this one reattach, never durable history (STA-4173).
return await this.checkpointQueue.runWithDeadline(
sessionId,
async () => {
try {
return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows)
} finally {
this.releaseNonFinalCheckpointAdmission(sessionId)
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
}
},
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
liveSnapshot,
{
onDeadline: () => {
if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) {
this.overlayDeadlineWarnedSessionIds.add(sessionId)
console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId)
}
}
}
)
}
protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean {
if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) {
if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.add(sessionId)
console.warn('[history] non-final checkpoint already in flight:', sessionId)
}
return false
}
if (
this.nonFinalCheckpointAdmissionSessionIds.size >=
(this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS
) {
this.reportNonFinalGlobalAdmissionDenial(sessionId)
return false
}
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalCheckpointAdmissionSessionIds.add(sessionId)
return true
}
protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void {
if (!this.nonFinalGlobalAdmissionWarningActive) {
this.nonFinalGlobalAdmissionWarningActive = true
console.warn('[history] non-final checkpoint global admission limit reached:', sessionId)
}
}
protected releaseNonFinalCheckpointAdmission(sessionId: string): void {
if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalGlobalAdmissionWarningActive = false
}
}
protected async compactDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
// Why compact before reading: an independent take/append races the 5s tick, can
// seq-gap the log, and would remount a stale checkpoint over the live window.
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'committed') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
}
if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) {
return checkpoint.snapshot ?? liveSnapshot
}
if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) {
return checkpoint.snapshot
}
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (!restoreInfo) {
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot: checkpoint.snapshot,
restoreInfo,
scrollbackRows
})
} catch (error) {
console.warn('[history] durable snapshot overlay failed:', sessionId, error)
return liveSnapshot
}
}
async clearBuffer(id: string): Promise<void> {
await this.client.request('clearScrollback', { sessionId: id })
this.markSessionDirty(id)
}
acknowledgeDataEvent(_id: string, _charCount: number): void {
// No flow control for daemon-backed terminals
}
}
@@ -0,0 +1,192 @@
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler'
import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state'
import type { GetSnapshotResult, TakePendingOutputResult } from './types'
export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler {
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
protected async writeSessionCheckpoint(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
if (!this.supportsIncrementalCheckpoints) {
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
if (result.snapshot && this.historyManager) {
const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot)
return checkpoint === 'retryable' ? 'deferred' : 'done'
}
return 'done'
}
if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) {
if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) {
return 'deferred'
}
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: opts.teardown,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return 'done'
}
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId
})
if (!take) {
return 'done'
}
if (take.overflowed) {
// Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
return 'done'
}
if (take.records.length === 0) {
return 'done'
}
if (!this.historyManager) {
return 'done'
}
const appendResult = await this.historyManager.appendIncrements(
sessionId,
take.seq,
take.records
)
if (appendResult === 'needs-checkpoint') {
// Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
}
return 'done'
}
protected async takeSnapshotAndCheckpoint(
sessionId: string,
opts: {
teardown: boolean
forceLiveSnapshot?: boolean
requireContinuityProof?: boolean
}
): Promise<SnapshotCheckpointResult> {
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId,
includeSnapshot: true,
teardownSnapshot: opts.teardown
})
if (take?.snapshot && this.historyManager) {
// Why require drainedRecords: an older daemon still empties the pending
// queue on includeSnapshot but omits the field. Treating absence as []
// would compact stale disk history and reset the log.
const snapshot =
take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed
? take.snapshot
: await this.buildDurableHistorySnapshot(
sessionId,
take.snapshot,
[...take.drainedRecords, ...take.records],
{
pendingRecordsAreComplete: take.seq === 1,
...(opts.requireContinuityProof === true
? { requiredPreviousPendingOutputSeq: take.seq - 1 }
: {})
}
)
const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, {
pendingOutputSeq: take.seq
})
if (checkpoint === 'retryable') {
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
this.markSessionDirty(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
if (checkpoint === 'unavailable') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0 && snapshot === take.snapshot) {
// Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'committed', snapshot }
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null }
}
protected async buildDurableHistorySnapshot(
sessionId: string,
liveSnapshot: NonNullable<TakePendingOutputResult['snapshot']>,
pendingRecords: TakePendingOutputResult['records'],
opts: {
pendingRecordsAreComplete: boolean
requiredPreviousPendingOutputSeq?: number
}
): Promise<NonNullable<TakePendingOutputResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (
(!restoreInfo && !opts.pendingRecordsAreComplete) ||
(opts.requiredPreviousPendingOutputSeq !== undefined &&
restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq)
) {
console.warn('[history] durable continuity unproven; using live snapshot:', sessionId)
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo,
pendingRecords
})
} catch (error) {
console.warn('[history] durable history rebuild failed:', sessionId, error)
return liveSnapshot
}
}
}
@@ -0,0 +1,251 @@
import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline'
import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle'
export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle {
protected stopCheckpointTimer(): void {
if (!this.checkpointTimer) {
return
}
clearTimeout(this.checkpointTimer)
this.checkpointTimer = null
}
protected stopCheckpointTimerIfIdle(): void {
if (this.dirtySessionVersions.size === 0) {
this.stopCheckpointTimer()
}
}
protected scheduleCheckpointTimer(): void {
if (
this.checkpointTimer ||
!this.historyManager ||
!this.supportsCheckpoints ||
this.dirtySessionVersions.size === 0
) {
return
}
// Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write.
this.checkpointTimer = setTimeout(
() => {
this.checkpointTimer = null
// Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes.
if (this.checkpointInFlight) {
this.scheduleCheckpointTimer()
return
}
const checkpoint = this.checkpointDirtySessions()
this.checkpointInFlight = checkpoint
void checkpoint
.finally(() => {
if (this.checkpointInFlight === checkpoint) {
this.checkpointInFlight = null
this.scheduleCheckpointTimer()
}
})
// Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here.
.catch(() => {})
},
(this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS
)
}
protected markSessionDirty(sessionId: string): void {
if (!this.activeSessionIds.has(sessionId)) {
return
}
this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1)
this.scheduleCheckpointTimer()
}
protected async checkpointDirtySessions(): Promise<void> {
if (!this.historyManager || this.dirtySessionVersions.size === 0) {
return
}
// Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces)
// while not dropping writes that arrive mid-checkpoint.
const versions = new Map(
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
)
if (versions.size === 0) {
this.dirtySessionVersions.clear()
this.stopCheckpointTimer()
return
}
const completed = await this.checkpointSessions(versions.keys())
for (const [sessionId, version] of versions) {
if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) {
this.dirtySessionVersions.delete(sessionId)
}
}
this.stopCheckpointTimerIfIdle()
}
/** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */
protected async runExclusiveCheckpoint(
operation: () => Promise<void>,
options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {}
): Promise<boolean> {
this.stopCheckpointTimer()
// Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together.
const previous = this.checkpointInFlight ?? Promise.resolve()
const checkpoint = previous.catch(() => {}).then(operation)
this.checkpointInFlight = checkpoint
// Why the release rides the checkpoint instead of the caller's await: a caller that walks away
// at its deadline must leave this checkpoint as the tail, so the durable write still runs to
// completion, still commits, and the next waiter still queues behind it (STA-4228).
const settled = checkpoint.then(
() => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty),
(err: unknown) => {
this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty)
throw err
}
)
if (options.callerDeadlineMs === undefined) {
await settled
return true
}
return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs)
}
protected releaseExclusiveCheckpoint(
checkpoint: Promise<void>,
rescheduleDirty: boolean | undefined
): void {
if (this.checkpointInFlight === checkpoint) {
this.checkpointInFlight = null
}
this.stopCheckpointTimer()
if (rescheduleDirty !== false) {
this.scheduleCheckpointTimer()
}
}
// Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
protected async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
for (const sessionId of completed) {
this.dirtySessionVersions.delete(sessionId)
}
}
protected async checkpointSessions(
sessionIds: Iterable<string>,
opts?: { final?: boolean; teardown?: boolean }
): Promise<Set<string>> {
const completed = new Set<string>()
if (!this.historyManager) {
return completed
}
const ids = Array.from(sessionIds)
let nextIndex = 0
const checkpointNext = async (): Promise<void> => {
for (;;) {
// No worker in this pass awaits abandoned work, so a full admission set cannot open here.
if (
opts?.final !== true &&
this.nonFinalCheckpointAdmissionSessionIds.size >=
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS
) {
const deferredSessionId = ids[nextIndex]
if (deferredSessionId !== undefined) {
this.reportNonFinalGlobalAdmissionDenial(deferredSessionId)
}
return
}
const index = nextIndex
nextIndex++
if (index >= ids.length) {
return
}
const sessionId = ids[index]
await this.checkpointSession(sessionId, {
final: opts?.final === true,
teardown: opts?.teardown === true
})
.then((result) => {
// Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires.
if (result === 'done') {
completed.add(sessionId)
}
})
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
}
}
// Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once.
const workers = Array.from(
{
length: Math.min(
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS,
ids.length
)
},
() => checkpointNext()
)
await Promise.all(workers)
return completed
}
// Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately.
protected isFullCheckpointCoolingDown(sessionId: string): boolean {
const last = this.lastFullCheckpointAt.get(sessionId)
if (last === undefined) {
return false
}
const elapsed = Date.now() - last
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window.
return (
elapsed >= 0 &&
elapsed <
(this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS
)
}
protected async checkpointSession(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
// Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and
// deferring there would silently drop what the user left on screen rather than delay it.
if (opts.final) {
return await this.checkpointQueue.run(sessionId, () =>
this.writeSessionCheckpoint(sessionId, opts)
)
}
// Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue
// still commits and the next tick retries this one. Nothing on disk is discarded.
if (
this.checkpointQueue.isSaturated(sessionId) ||
!this.tryAdmitNonFinalCheckpoint(sessionId)
) {
return 'deferred'
}
const run = async (): Promise<'done' | 'deferred'> => {
try {
return await this.writeSessionCheckpoint(sessionId, opts)
} finally {
this.releaseNonFinalCheckpointAdmission(sessionId)
this.periodicDeadlineWarnedSessionIds.delete(sessionId)
}
}
return await this.checkpointQueue.runWithDeadline(
sessionId,
run,
(this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS,
'deferred',
{
onDeadline: () => {
if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) {
this.periodicDeadlineWarnedSessionIds.add(sessionId)
console.warn('[history] periodic checkpoint deadline exceeded:', sessionId)
}
},
onAbandonedRejection: (error) => {
console.warn('[history] checkpoint failed:', sessionId, error)
}
}
)
}
}
@@ -0,0 +1,156 @@
import { readFile } from 'node:fs/promises'
import {
classifyDaemonAuditFailure,
type DaemonAuditObservation,
type DaemonAuditTrigger
} from './daemon-audit-classifier'
import {
exactDaemonIncarnationForPidRecord,
sameEndpointIdentity
} from './daemon-endpoint-incarnation'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { notifyDaemonAuditListeners } from './daemon-listener-registry'
import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions'
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions {
protected async ensureConnected(deadlineMs?: number): Promise<void> {
try {
// Why: destructive teardown bounds the handshake by its deadline so a wedged
// connect fails fast; undefined keeps the default connect behavior.
await (deadlineMs !== undefined
? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
: this.client.ensureConnected())
} finally {
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
this.releasePendingRespawnAdoptionLease()
}
this.recordAuthenticatedIdentity()
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
const isFreshConnection = this.removeEventListener === null
this.setupEventRouting()
this.scheduleCheckpointTimer()
this.flushOwedProducerResumes()
if (isFreshConnection) {
this.resyncBackgroundedSessions()
}
}
protected recordAuthenticatedIdentity(): void {
const current = this.client.getDaemonIdentity()
if (!current) {
return
}
const previous = this.lastAuthenticatedIdentity
if (previous && sameEndpointIdentity(previous, current)) {
return
}
this.lastAuthenticatedIdentity = { ...current }
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
if (!previous) {
return
}
const event = { previous: { ...previous }, current: { ...current } }
notifyDaemonAuditListeners(this.identityChangeListeners, event)
}
protected async resolveExactDaemonIncarnation(
exactIncarnation: ExactDaemonIncarnation | null
): Promise<ExactDaemonIncarnation | null> {
if (
!exactIncarnation ||
process.platform !== 'linux' ||
(exactIncarnation.linuxStartTicks && exactIncarnation.bootId)
) {
return exactIncarnation
}
const cachedIncarnation = exactDaemonIncarnationForPidRecord(
exactIncarnation.identity,
this.pidRecord
)
if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) {
return cachedIncarnation
}
const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity)
this.pidRecord = pidRecord ?? this.pidRecord
return pidRecord?.linuxStartTicks && pidRecord.bootId
? {
identity: { ...exactIncarnation.identity },
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: exactIncarnation
}
protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void {
if (
exactIncarnation &&
this.lastAuthenticatedIdentity &&
sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity)
) {
this.exactDaemonIncarnation = exactIncarnation
}
}
protected async readMatchingPidRecord(
identity: DaemonEndpointIdentity
): Promise<ParsedDaemonPid | null> {
if (!this.pidPath) {
return null
}
try {
const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8'))
return parsed?.pid === identity.pid &&
parsed.startedAtMs === identity.startedAtMs &&
parsed.launchNonce === identity.launchNonce
? parsed
: null
} catch {
return null
}
}
protected observeAuditFailure(
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
exactIncarnation = this.exactDaemonIncarnation,
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
endpointGoneProof?: 'windows_named_pipe_missing'
): void {
void this.resolveExactDaemonIncarnation(exactIncarnation)
.then((resolvedIncarnation) => {
this.cacheExactDaemonIncarnation(resolvedIncarnation)
return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, {
additionalEvidenceSources,
endpointGoneProof
})
})
.then((observation) => this.publishAuditObservation(observation))
.catch(() => {})
}
protected publishAuditObservation(observation: DaemonAuditObservation): void {
this.lastAuditObservation = observation
this.trackAuditEligibility(observation)
notifyDaemonAuditListeners(this.auditObservationListeners, observation)
}
protected resyncBackgroundedSessions(): void {
for (const id of this.backgroundedSessionIds) {
// Harmless no-op for sessions the daemon doesn't know (yet).
this.client.notify('setSessionBackground', { sessionId: id, background: true })
}
}
protected flushOwedProducerResumes(): void {
if (this.producerResumesOwedOnReconnect.size === 0) {
return
}
for (const id of this.producerResumesOwedOnReconnect) {
// Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency.
this.client.notify('resumePty', { sessionId: id })
}
this.producerResumesOwedOnReconnect.clear()
}
}
@@ -0,0 +1,290 @@
import { existsSync } from 'node:fs'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import type { ListSessionsResult } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/types'
export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence {
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
protected isRetiredEndpointTokenMissing(): boolean {
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
}
protected async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing()
if (missingRetiredEndpointToken) {
this.observeAuditFailure(
'token_missing_after_authenticated_disconnect',
this.exactDaemonIncarnation,
['token_file']
)
}
if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) {
throw err
}
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn().finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
try {
return await fn()
} finally {
// Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn).
this.releasePendingRespawnAdoptionLease()
}
}
}
protected reconnectAfterWriteFailure(): void {
if (
this.writeRecoveryPromise ||
this.writeRecoveryAttempted ||
this.respawnAdoptionClosed ||
!this.respawnFn
) {
return
}
this.writeRecoveryAttempted = true
// Why: the dead endpoint took down every session on this daemon. Signal all
// active panes now — while they are still in activeSessionIds, so the
// renderer's liveness gate still reads them live — so background panes
// remount + re-attach alongside the one that was written, instead of being
// left frozen with silently dropped input until each is typed into.
this.notifyActiveSessionsWriteUnavailable()
const recovery = this.withDaemonRetry(() => this.ensureConnected())
.catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error))
.finally(() => {
this.releasePendingRespawnAdoptionLease()
if (this.writeRecoveryPromise === recovery) {
this.writeRecoveryPromise = null
}
})
this.writeRecoveryPromise = recovery
}
protected notifyActiveSessionsWriteUnavailable(): void {
// Snapshot first: a listener that kills a pane would mutate activeSessionIds
// mid-iteration and silently skip the sibling this fan-out exists to reach.
const ids = [...this.activeSessionIds]
for (const id of ids) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.emitWriteUnavailable(id)
}
}
protected clearSessionAwaitingDaemonRecovery(sessionId: string): void {
this.sessionsAwaitingDaemonRecovery.delete(sessionId)
if (this.sessionsAwaitingDaemonRecovery.size === 0) {
this.writeRecoveryAttempted = false
}
}
protected async withHistorySpawnLock<T>(
sessionId: string,
operation: () => Promise<T>
): Promise<T> {
if (!this.historyManager) {
return await operation()
}
const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.then(
() => current,
() => current
)
this.historySpawnLocks.set(sessionId, tail)
await previous.catch(() => {})
try {
return await operation()
} finally {
release()
if (this.historySpawnLocks.get(sessionId) === tail) {
this.historySpawnLocks.delete(sessionId)
}
}
}
protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void> {
if (!this.respawnFn) {
return
}
const health = await getMacDaemonSystemResolverHealth(
this.socketPath,
this.tokenPath,
this.protocolVersion
)
if (health !== 'unhealthy') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified'
: `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return
}
// Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs.
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS system resolver unavailable - respawning daemon',
'unhealthy_resolver'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
/** Replace a stale packaged daemon only after its live sessions drain. */
protected async replaceStaleBundleDaemonBeforeNewPty(): Promise<void> {
if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) {
return
}
if (!this.staleBundleReplacementPromise) {
this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce(
this.runtimeDir,
this.packagedAppVersion
).finally(() => {
this.staleBundleReplacementPromise = null
})
}
await this.staleBundleReplacementPromise
}
protected async replaceStaleBundleDaemonOnce(
runtimeDir: string,
packagedAppVersion: string
): Promise<void> {
const stale = await isDaemonStaleForCurrentBundle(
runtimeDir,
this.socketPath,
this.tokenPath,
packagedAppVersion,
this.protocolVersion
)
if (!stale) {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified'
: `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] Packaged daemon is stale - respawning from the current app bundle',
'stale_bundle'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
/** Replace a TCC-severed daemon only after its live sessions drain. */
protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void> {
// Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS.
if (!this.respawnFn || !this.runtimeDir) {
return
}
const health = await getMacDaemonTccAttributionHealth(
this.runtimeDir,
this.socketPath,
this.tokenPath,
this.protocolVersion
)
if (health !== 'severed') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified'
: `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS TCC attribution severed - respawning daemon under the current app binary',
'severed_tcc_attribution'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
protected async getDaemonLiveSessionCount(): Promise<number | null> {
try {
await this.client.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
}
}
protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void {
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.backgroundStreamListeners]) {
listener(payload)
}
}
protected async doRespawn(
message = '[daemon] Daemon died — respawning',
reason: DaemonRespawnReason = 'daemon_died'
): Promise<void> {
console.warn(message)
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
const releaseAdoptionLease = await this.respawnFn!(reason)
if (this.respawnAdoptionClosed) {
// Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns.
releaseAdoptionLease?.()
throw new Error('Daemon adapter closed during respawn')
}
this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null
}
protected releasePendingRespawnAdoptionLease(): void {
const release = this.pendingRespawnAdoptionRelease
this.pendingRespawnAdoptionRelease = null
release?.()
}
}
@@ -0,0 +1,157 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/types'
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
onData(
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
if (idx !== -1) {
this.dataListeners.splice(idx, 1)
}
}
}
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
this.backgroundStreamListeners.push(callback)
return () => {
const idx = this.backgroundStreamListeners.indexOf(callback)
if (idx !== -1) {
this.backgroundStreamListeners.splice(idx, 1)
}
}
}
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
return () => {}
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
if (idx !== -1) {
this.exitListeners.splice(idx, 1)
}
}
}
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
this.writeUnavailableListeners.push(callback)
return () => {
const idx = this.writeUnavailableListeners.indexOf(callback)
if (idx !== -1) {
this.writeUnavailableListeners.splice(idx, 1)
}
}
}
protected emitWriteUnavailable(id: string): void {
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.writeUnavailableListeners]) {
listener({ id })
}
}
dispose(): void {
this.respawnAdoptionClosed = true
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.overlayDeadlineWarnedSessionIds.clear()
this.periodicDeadlineWarnedSessionIds.clear()
this.nonFinalAdmissionDeniedSessionIds.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.auditObservationListeners.length = 0
this.identityChangeListeners.length = 0
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
// cleanly ended so they don't trigger false cold restores.
if (this.historyManager) {
void this.historyManager
.dispose()
.catch((err) => console.warn('[history] dispose failed:', err))
}
this.client.disconnect()
}
async establishLifecycleLease(): Promise<void> {
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
return
}
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
await this.client.ensureConnected()
this.recordAuthenticatedIdentity()
}
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
async disconnectOnly(): Promise<void> {
if (!this.disconnectOnlyPromise) {
this.respawnAdoptionClosed = true
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.releasePendingRespawnAdoptionLease()
this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns])
}
await this.disconnectOnlyPromise
}
protected async finishDisconnectOnly(keepHistoryShutdowns: Promise<void>[]): Promise<void> {
// Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations.
await Promise.allSettled(keepHistoryShutdowns)
this.respawnAdoptionClosed = true
// Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon
// later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs.
await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), {
rescheduleDirty: false
})
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
// Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window.
for (const id of this.pausedProducerSessionIds) {
this.client.notify('resumePty', { sessionId: id })
}
this.pausedProducerSessionIds.clear()
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's empty; a shared budget keeps this 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 once its auth sockets close and it proves itself empty.
}
}
this.client.disconnect()
}
}
@@ -0,0 +1,13 @@
export class FinalCheckpointWaitExpiredError extends Error {
constructor(sessionId: string) {
super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`)
this.name = 'FinalCheckpointWaitExpiredError'
}
}
export class TerminalKilledError extends Error {
constructor(sessionId: string) {
super(`Session "${sessionId}" was explicitly killed`)
this.name = 'TerminalKilledError'
}
}
@@ -0,0 +1,177 @@
import { isShellProcess } from '../../shared/agent-detection'
import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots'
import { parsePtySessionId } from './pty-session-id'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
type ListSessionsResult,
type SessionInfo
} from './types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean {
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
async hasChildProcesses(id: string): Promise<boolean> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return true
}
return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id))
}
async inspectProcess(id: string): Promise<PtyProcessInspection> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return { foregroundProcess: null, hasChildProcesses: true, unavailable: true }
}
if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) {
// Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the
// one call they do support instead of throwing, or completion detection stays dead until recreate.
// Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than
// reading as an idle foreground and dispatching a false completion.
const { foregroundProcess } = await this.client.request<{
foregroundProcess: string | null
}>('getForegroundProcess', { sessionId: id })
return {
foregroundProcess,
hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess)
}
}
return this.client.request<{
foregroundProcess: string | null
hasChildProcesses: boolean
}>('inspectProcess', { sessionId: id })
}
async getForegroundProcess(id: string): Promise<string | null> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return null
}
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
'getForegroundProcess',
{ sessionId: id }
)
return result.foregroundProcess
} catch {
return null
}
}
async confirmForegroundProcess(id: string): Promise<string | null> {
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
'confirmForegroundProcess',
{ sessionId: id }
)
return result.foregroundProcess
} catch {
return null
}
}
async serialize(ids: string[]): Promise<string> {
const sessions: Record<string, { initialCwd?: string }> = {}
for (const id of ids) {
sessions[id] = { initialCwd: this.initialCwds.get(id) }
}
return JSON.stringify(sessions)
}
async revive(_state: string): Promise<void> {
// Sessions already live in the daemon — no revival needed
}
/** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId
* no longer exists, and caches alive session IDs.
*
* IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed
* worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live
* worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false
* orphans. No production caller yet; wire the alias in when it gains one. */
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
}> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
const alive: string[] = []
const killed: string[] = []
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
try {
await this.client.request('kill', { sessionId: session.sessionId })
} catch {
/* already dead */
}
killed.push(session.sessionId)
} else {
alive.push(session.sessionId)
// Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data.
this.activeSessionIds.add(session.sessionId)
await this.reconcileLiveSessionHistory(session).catch((err) =>
console.warn('[history] live-session reconciliation failed:', session.sessionId, err)
)
}
}
return { alive, killed }
}
protected async reconcileLiveSessionHistory(session: SessionInfo): Promise<void> {
const historyManager = this.historyManager
const historyReader = this.historyReader
if (!historyManager || !historyReader) {
return
}
await this.withHistorySpawnLock(session.sessionId, async () => {
if (historyManager.hasWriter(session.sessionId)) {
return
}
const probe = historyReader.probeRestorableHistory(session.sessionId)
if (probe.status === 'unreadable') {
return
}
if (probe.status === 'none') {
await historyManager.openSession(session.sessionId, {
cwd: session.cwd ?? '',
cols: session.cols,
rows: session.rows
})
} else {
const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId)
try {
const detection = await historyReader.detectColdRestoreState(session.sessionId, {
wslDistro: session.wslDistro ?? undefined
})
if (
detection.status === 'unreadable' ||
(detection.status === 'restored' && detection.hasUnreadableRecovery)
) {
historyManager.suspendSession(session.sessionId, recoveryFreeze)
return
}
historyManager.reopenSession(session.sessionId, recoveryFreeze)
} finally {
historyManager.abandonRecoveryFreeze(recoveryFreeze)
}
}
if (historyManager.hasWriter(session.sessionId)) {
this.sessionsNeedingFullCheckpoint.add(session.sessionId)
this.sessionsNeedingContinuityCheckpoint.add(session.sessionId)
this.lastFullCheckpointAt.delete(session.sessionId)
this.markSessionDirty(session.sessionId)
}
})
}
}
@@ -0,0 +1,13 @@
import type { CreateOrAttachResult } from './types'
import type { PtySpawnResult } from '../providers/types'
export function providerSequenceFromCreateOrAttach(
result: CreateOrAttachResult
): PtySpawnResult['providerSequence'] {
if (result.isNew) {
return { value: 0, generation: 'reset' }
}
return typeof result.snapshot?.outputSequence === 'number'
? { value: result.snapshot.outputSequence, generation: 'continued' }
: undefined
}
+319
View File
@@ -0,0 +1,319 @@
import { DaemonClient } from './client'
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
import type {
DaemonAuditContext,
DaemonAuditObservation,
DaemonAuditTrigger
} from './daemon-audit-classifier'
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { readDaemonPidRecord, sameEndpointIdentity } from './daemon-endpoint-incarnation'
import { removeDaemonListener } from './daemon-listener-registry'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
supportsPtyStartupIngress,
type ListSessionsResult,
type TakePendingOutputResult
} from './types'
import { ColdRestorePayloadCache } from './cold-restore-payload-cache'
import {
HistoryManager,
type HistoryCheckpointResult,
type HistoryRecoveryFreeze
} from './history-manager'
import { HistoryReader } from './history-reader'
import type { PtyBackgroundStreamEvent } from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
export type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
ignoredExitIncarnationIds: Set<string>
ignoreNextExit: boolean
acceptsUnroutedExit: boolean
}
export type HistoryRecoveryContext = {
freeze: HistoryRecoveryFreeze | null
unreadableSessionId: string | null
identityChanged: boolean
}
export type SnapshotCheckpointResult = {
checkpoint: HistoryCheckpointResult
snapshot: NonNullable<TakePendingOutputResult['snapshot']> | null
}
export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
pidPath?: string
profileScope?: string
protocolVersion?: number
historyPath?: string
runtimeDir?: string
packagedAppVersion?: string | null
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
executionHostId?: ExecutionHostId
}
export type DaemonRespawnReason =
| 'daemon_died'
| 'unhealthy_resolver'
| 'stale_bundle'
| 'severed_tcc_attribution'
export type DaemonIdentityChangeEvent = {
previous: DaemonEndpointIdentity
current: DaemonEndpointIdentity
}
export abstract class DaemonPtyRuntimeState {
readonly protocolVersion: number
protected socketPath: string
protected tokenPath: string
protected pidPath: string | null
protected pidRecord: ParsedDaemonPid | null
protected client: DaemonClient
protected auditContext: DaemonAuditContext
protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null
protected lastAuditObservation: DaemonAuditObservation | null = null
protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker()
protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
protected historyManager: HistoryManager | null
protected historyReader: HistoryReader | null
protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null
protected runtimeDir: string | null
protected packagedAppVersion: string | null
protected readonly executionHostId: ExecutionHostId
protected readonly ownerEndpointRef: string
protected pendingRespawnAdoptionRelease: (() => void) | null = null
protected respawnAdoptionClosed = false
protected respawnPromise: Promise<void> | null = null
protected staleBundleReplacementPromise: Promise<void> | null = null
protected writeRecoveryPromise: Promise<void> | null = null
protected writeRecoveryAttempted = false
protected dataListeners: ((payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
protected exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
cause?: TerminalExitCause
}) => void)[] = []
protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
protected removeEventListener: (() => void) | null = null
protected initialCwds = new Map<string, string>()
protected wslDistrosBySessionId = new Map<string, string>()
protected killedSessionTombstones = new Map<string, number>()
protected sleepRestoreSessionIds = new Set<string>()
protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => {
this.sleepRestoreSessionIds.delete(sessionId)
})
protected activeSessionIds = new Set<string>()
protected getSizeUnsupported = false
protected sessionsAwaitingDaemonRecovery = new Set<string>()
protected sessionIncarnations = new Map<string, string>()
protected sessionOwnerIdentities = new Map<string, DaemonEndpointIdentity>()
protected livenessInventoryInFlight: Promise<ListSessionsResult> | null = null
protected pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
protected pendingClaimSpawnOperations = new Set<PendingDaemonSpawnOperation>()
protected historySpawnLocks = new Map<string, Promise<void>>()
protected dirtySessionVersions = new Map<string, number>()
protected sessionsNeedingFullCheckpoint = new Set<string>()
protected sessionsNeedingLiveCheckpoint = new Set<string>()
protected sessionsNeedingContinuityCheckpoint = new Set<string>()
protected checkpointTimer: ReturnType<typeof setTimeout> | null = null
protected checkpointInFlight: Promise<void> | null = null
protected checkpointQueue = new CheckpointSessionQueue()
protected nonFinalCheckpointAdmissionSessionIds = new Set<string>()
protected nonFinalAdmissionDeniedSessionIds = new Set<string>()
protected nonFinalGlobalAdmissionWarningActive = false
protected overlayDeadlineWarnedSessionIds = new Set<string>()
protected periodicDeadlineWarnedSessionIds = new Set<string>()
protected keepHistoryShutdowns = new Set<Promise<void>>()
protected disconnectOnlyPromise: Promise<void> | null = null
protected supportsCheckpoints: boolean
protected supportsIncrementalCheckpoints: boolean
protected supportsProducerFlowControl: boolean
protected supportsAuthoritativeBufferSnapshots: boolean
protected supportsStartupIngress: boolean
protected pausedProducerSessionIds = new Set<string>()
protected backgroundedSessionIds = new Set<string>()
protected producerResumesOwedOnReconnect = new Set<string>()
protected static CHECKPOINT_INTERVAL_MS = 5_000
protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
protected static MAX_CONCURRENT_CHECKPOINTS = 4
protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
protected lastFullCheckpointAt = new Map<string, number>()
protected abstract observeAuditFailure(
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
exactIncarnation?: ExactDaemonIncarnation | null,
additionalEvidenceSources?: readonly DaemonEvidenceSource[],
endpointGoneProof?: 'windows_named_pipe_missing'
): void
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
this.pidRecord = readDaemonPidRecord(this.pidPath)
this.auditContext = {
protocolGeneration: this.protocolVersion,
provider: 'local-daemon',
endpoint: opts.socketPath,
tokenPath: opts.tokenPath,
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
profileScope: opts.profileScope ?? ''
}
this.client = new DaemonClient({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
protocolVersion: opts.protocolVersion
})
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
this.packagedAppVersion = opts.packagedAppVersion ?? null
this.executionHostId = opts.executionHostId ?? 'local'
this.ownerEndpointRef = `daemon-v${this.protocolVersion}`
this.supportsCheckpoints = this.protocolVersion >= 4
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
this.supportsAuthoritativeBufferSnapshots =
this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION
this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion)
this.client.onDisconnected(() => {
if (!this.respawnAdoptionClosed) {
this.writeRecoveryAttempted = false
for (const id of this.activeSessionIds) {
this.sessionsAwaitingDaemonRecovery.add(id)
}
}
for (const id of this.pausedProducerSessionIds) {
this.producerResumesOwedOnReconnect.add(id)
}
this.pausedProducerSessionIds.clear()
this.observeAuditFailure('transport_closed')
})
}
supportsGitCredentialGuardHost(): boolean {
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
}
canProvideAuthoritativeBufferSnapshot(_id: string): boolean {
return this.supportsAuthoritativeBufferSnapshots
}
protected get canDelegateBackgroundToDaemon(): boolean {
return (
this.supportsAuthoritativeBufferSnapshots &&
supportsMode2031UnsubscribeFact(this.protocolVersion)
)
}
getHistoryManager(): HistoryManager | null {
return this.historyManager
}
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
}
protected ownerIdentity(
sessionIncarnationId?: PtyIncarnationId
): TerminalOwnerIdentity | undefined {
const daemon = this.lastAuthenticatedIdentity
if (!daemon || !sessionIncarnationId) {
return undefined
}
return {
executionHostId: this.executionHostId,
ownerKind: 'daemon',
ownerIncarnationId: daemon.launchNonce,
sessionIncarnationId,
protocolVersion: this.protocolVersion,
endpointRef: this.ownerEndpointRef
}
}
getTerminalOwnerIdentity(id: string): TerminalOwnerIdentity | null {
if (!this.hasExactSessionAuthority(id)) {
return null
}
return this.ownerIdentity(this.sessionIncarnations.get(id)) ?? null
}
protected recordSessionAuthority(id: string, incarnationId?: PtyIncarnationId): void {
if (!incarnationId || !this.lastAuthenticatedIdentity) {
return
}
this.sessionIncarnations.set(id, incarnationId)
this.sessionOwnerIdentities.set(id, { ...this.lastAuthenticatedIdentity })
}
protected hasExactSessionAuthority(
id: string,
expectedIncarnationId?: PtyIncarnationId
): boolean {
const sessionIncarnationId = this.sessionIncarnations.get(id)
const ownerIdentity = this.sessionOwnerIdentities.get(id)
const currentIdentity = this.lastAuthenticatedIdentity
return Boolean(
sessionIncarnationId &&
ownerIdentity &&
currentIdentity &&
sameEndpointIdentity(ownerIdentity, currentIdentity) &&
(expectedIncarnationId === undefined || expectedIncarnationId === sessionIncarnationId)
)
}
getLastAuditObservation(): DaemonAuditObservation | null {
return this.lastAuditObservation
}
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
this.identityChangeListeners.push(listener)
return () => removeDaemonListener(this.identityChangeListeners, listener)
}
onAuditEligibilityObservation(
listener: (observation: DaemonAuditObservation) => void
): () => void {
this.auditObservationListeners.push(listener)
return () => removeDaemonListener(this.auditObservationListeners, listener)
}
supportsAgentSessionClaims(): boolean {
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
}
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.supportsAgentSessionClaims()
}
supportsAgentSessionCreateOperations(): boolean {
return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
}
}
@@ -0,0 +1,105 @@
import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
import { sameEndpointIdentity } from './daemon-endpoint-incarnation'
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
import type { ListSessionsResult } from './types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
export abstract class DaemonPtySessionAuthority extends DaemonPtySessionSpawn {
private listSessionsForLiveness(): Promise<ListSessionsResult> {
if (this.livenessInventoryInFlight) {
return this.livenessInventoryInFlight
}
const request = this.client.request<ListSessionsResult>(
'listSessions',
undefined,
LIVENESS_PROBE_TIMEOUT_MS
)
this.livenessInventoryInFlight = request
void request.then(
() => {
if (this.livenessInventoryInFlight === request) {
this.livenessInventoryInFlight = null
}
},
() => {
if (this.livenessInventoryInFlight === request) {
this.livenessInventoryInFlight = null
}
}
)
return request
}
async probePtyLiveness(
id: string,
expectedIncarnationId?: PtyIncarnationId,
expectedOwnerIdentity?: TerminalOwnerIdentity
): Promise<boolean | null> {
try {
await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS)
const ownerIdentityAtProbe = this.lastAuthenticatedIdentity
? { ...this.lastAuthenticatedIdentity }
: null
const canConfirmAbsence =
this.hasExactSessionAuthority(id, expectedIncarnationId) &&
(expectedOwnerIdentity === undefined ||
this.ownerIdentity(expectedIncarnationId ?? this.sessionIncarnations.get(id))
?.ownerIncarnationId === expectedOwnerIdentity.ownerIncarnationId)
if (expectedOwnerIdentity !== undefined && !canConfirmAbsence) {
return null
}
if (
(expectedIncarnationId === undefined || canConfirmAbsence) &&
!this.getSizeUnsupported &&
this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION
) {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id },
LIVENESS_PROBE_TIMEOUT_MS
)
if (
!ownerIdentityAtProbe ||
!this.lastAuthenticatedIdentity ||
!sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity)
) {
return null
}
return result.size !== null ? true : canConfirmAbsence ? false : null
} catch (error) {
if (!isUnknownRequestTypeError(error)) {
throw error
}
this.getSizeUnsupported = true
}
}
const { sessions } = await this.listSessionsForLiveness()
if (
!ownerIdentityAtProbe ||
!this.lastAuthenticatedIdentity ||
!sameEndpointIdentity(ownerIdentityAtProbe, this.lastAuthenticatedIdentity)
) {
return null
}
const liveSession = sessions.find((session) => session.sessionId === id && session.isAlive)
if (liveSession) {
this.recordSessionAuthority(id, liveSession.incarnationId)
if (
expectedIncarnationId !== undefined &&
liveSession.incarnationId !== expectedIncarnationId
) {
return canConfirmAbsence ? false : null
}
return true
}
return canConfirmAbsence ? false : null
} catch {
return null
}
}
}
@@ -0,0 +1,342 @@
import type { ColdRestorePayload } from './cold-restore-payload-cache'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySessionAuthority } from './daemon-pty-session-authority'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import type { ColdRestoreInfo } from './history-reader'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { SessionNotFoundError, type CreateOrAttachResult } from './types'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import type { PtySpawnResult } from '../providers/types'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
export { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-authority'
const MAX_TOMBSTONES = 1000
export abstract class DaemonPtySessionControl extends DaemonPtySessionAuthority {
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | void> {
await this.ensureConnected()
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(id, false)
}
// Why size-first: attach must ride the session's own geometry — a fixed
// 80×24 here could resize a live agent's TUI — and a null size means the
// daemon cannot prove the session, so refuse rather than risk a create.
const size = await this.getAppliedSize(id)
if (!size) {
throw new SessionNotFoundError(id)
}
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
sessionId: id,
cols: size.cols,
rows: size.rows,
attachOnly: true
})
if (result.isNew) {
// Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn
// instead of publishing a fresh shell as an attach.
await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => {
// Why surface, not swallow: a failed retire leaves an untracked orphan shell.
console.warn('[daemon] attach-only retire of accidental legacy spawn failed', {
sessionId: id,
error
})
})
throw new SessionNotFoundError(id)
}
this.clearSessionAwaitingDaemonRecovery(id)
const providerSequence = providerSequenceFromCreateOrAttach(result)
return providerSequence ? { providerSequence } : undefined
}
hasPty(id: string): boolean {
return this.activeSessionIds.has(id)
}
write(id: string, data: string): boolean {
const recoverable = this.prepareWrite(id)
return this.finishWrite(
id,
this.client.notify('write', {
sessionId: id,
data,
...(this.sessionIncarnations.get(id)
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
: {})
}),
recoverable
)
}
async writeWithSettlement(id: string, data: string): Promise<boolean> {
const recoverable = this.prepareWrite(id)
return this.finishWrite(
id,
await this.client.notifyWithSettlement('write', {
sessionId: id,
data,
...(this.sessionIncarnations.get(id)
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
: {})
}),
recoverable
)
}
protected prepareWrite(id: string): boolean {
this.markSessionDirty(id)
// Why recoverable and not just active: rejecting a write asks the pane to remount,
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
// so its reattach fails and the pane rebuilds empty — losing scrollback the user
// could still read. Keep the pre-existing silent drop for those.
const recoverable =
this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn)
if (
recoverable &&
(this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected())
) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.reconnectAfterWriteFailure()
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
}
return recoverable
}
protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
if (!delivered && recoverable) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.reconnectAfterWriteFailure()
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
}
return delivered
}
resize(id: string, cols: number, rows: number): void {
this.markSessionDirty(id)
this.client.notify('resize', {
sessionId: id,
cols,
rows,
...(this.sessionIncarnations.get(id)
? { expectedIncarnationId: this.sessionIncarnations.get(id) }
: {})
})
}
pauseProducer(id: string): void {
if (!this.supportsProducerFlowControl) {
return
}
this.pausedProducerSessionIds.add(id)
this.client.notify('pausePty', { sessionId: id })
}
resumeProducer(id: string): void {
this.producerResumesOwedOnReconnect.delete(id)
if (!this.supportsProducerFlowControl) {
return
}
this.pausedProducerSessionIds.delete(id)
this.client.notify('resumePty', { sessionId: id })
}
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
setPtyBackgrounded(id: string, background: boolean): void {
if (!this.supportsProducerFlowControl) {
return
}
// Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream.
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
// retract it, so a TUI exiting while hidden would strand the subscription and the
// next theme flip would inject CSI 997 into its replacement shell. Declining to
// background keeps main's scanner — which emits both facts — authoritative.
const safeBackground = this.canDelegateBackgroundToDaemon && background
if (safeBackground) {
this.backgroundedSessionIds.add(id)
} else {
this.backgroundedSessionIds.delete(id)
}
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
}
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
if (opts.keepHistory && this.disconnectOnlyPromise) {
throw new Error('Cannot keep history after daemon disconnect has started')
}
const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts))
if (!opts.keepHistory) {
await shutdown
return
}
this.keepHistoryShutdowns.add(shutdown)
try {
await shutdown
} finally {
this.keepHistoryShutdowns.delete(shutdown)
}
}
protected async shutdownWithHistoryLock(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
// Why: shutdown can be the first lazy-client operation after restart; connect
// before killing so a healthy daemon session is not orphaned (#7742). Connect,
// the final-checkpoint wait, and kill all share the caller's one absolute
// deadline, so neither a wedged handshake nor a stalled history write can burn
// the whole teardown budget before the kill even starts. Only the waits are
// bounded — the checkpoint itself stays deadline-free and lossless (STA-4228).
await this.ensureConnected(opts.deadlineMs)
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
// Force a final snapshot so wake can restore the pane users left.
if (opts.keepHistory) {
const committed = await this.runExclusiveCheckpoint(
async () => {
await this.checkpointSessions([id], { final: true, teardown: true })
},
{ callerDeadlineMs: opts.deadlineMs }
)
// Why throw instead of killing anyway: the snapshot the caller asked us to prove is still
// being written. Killing here would race the wake-time restore source to disk, so report the
// pty unverified and leave it alive — worktree sleep declines to commit it and retries.
if (!committed) {
throw new FinalCheckpointWaitExpiredError(id)
}
const wslDistro = this.wslDistrosBySessionId.get(id)
const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro })
const detected = detection?.status === 'restored' ? detection.restoreInfo : null
const restoreInfo = detected
? {
...detected,
cwd:
normalizeWslColdRestoreCwd({
recoveredCwd: detected.cwd,
requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(),
wslDistro
}) ?? ''
}
: null
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
if (this.coldRestoreCache.has(id)) {
this.sleepRestoreSessionIds.add(id)
}
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
this.historyManager?.suspendSession(id)
} else if (
detection?.status === 'unreadable' ||
(detection?.status === 'restored' && detection.hasUnreadableRecovery)
) {
this.historyManager?.suspendSession(id)
}
}
await this.client.request(
'kill',
{ sessionId: id, immediate: opts.immediate ?? false },
remainingDaemonRequestTimeoutMs(opts.deadlineMs)
)
this.activeSessionIds.delete(id)
this.sessionIncarnations.delete(id)
this.sessionOwnerIdentities.delete(id)
this.clearSessionAwaitingDaemonRecovery(id)
this.dirtySessionVersions.delete(id)
if (!opts.keepHistory) {
this.coldRestoreCache.delete(id)
this.sleepRestoreSessionIds.delete(id)
}
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
this.sessionsNeedingFullCheckpoint.delete(id)
this.sessionsNeedingLiveCheckpoint.delete(id)
this.sessionsNeedingContinuityCheckpoint.delete(id)
this.overlayDeadlineWarnedSessionIds.delete(id)
this.periodicDeadlineWarnedSessionIds.delete(id)
this.nonFinalAdmissionDeniedSessionIds.delete(id)
this.lastFullCheckpointAt.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
this.wslDistrosBySessionId.delete(id)
// Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory).
if (this.historyManager && !opts.keepHistory) {
await this.historyManager
.removeSession(id)
.catch((err) => console.warn('[history] removeSession failed:', id, err))
}
// Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory.
if (!opts.keepHistory) {
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
if (this.killedSessionTombstones.size > MAX_TOMBSTONES) {
const oldest = this.killedSessionTombstones.keys().next().value
if (oldest) {
this.killedSessionTombstones.delete(oldest)
}
}
}
}
ackColdRestore(sessionId: string): void {
this.coldRestoreCache.delete(sessionId)
this.sleepRestoreSessionIds.delete(sessionId)
}
clearTombstone(sessionId: string): void {
this.killedSessionTombstones.delete(sessionId)
}
protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake.
const scrollback = restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
if (!scrollback) {
return null
}
return {
scrollback,
cwd: restoreInfo.cwd,
cols: restoreInfo.cols,
rows: restoreInfo.rows,
oscLinks: restoreInfo.oscLinks,
...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {})
}
}
async sendSignal(id: string, signal: string): Promise<void> {
await this.client.request('signal', { sessionId: id, signal })
}
async getCwd(id: string): Promise<string> {
try {
const result = await this.client.request<{ cwd: string | null }>('getCwd', {
sessionId: id
})
return result.cwd ?? ''
} catch {
return ''
}
}
async getInitialCwd(id: string): Promise<string> {
return this.initialCwds.get(id) ?? ''
}
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
)
return result.size ?? null
} catch {
return null
}
}
}
@@ -0,0 +1,181 @@
import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import {
isAgentSessionOwnerBinding,
type AgentSessionOwnerBinding
} from '../../shared/agent-session-host-authority'
import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner'
import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot'
import { recordAuthenticatedInventory } from './daemon-audit-classifier'
import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors'
import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import { parsePtySessionId } from './pty-session-id'
import type { ListSessionsResult, SessionInfo } from './types'
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
import type { PtyProcessInfo } from '../providers/types'
export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection {
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
// Why: snapshotted before the request so ids spawned mid-flight can never
// be reconciled away below.
const preRequestActiveIds = new Set(this.activeSessionIds)
try {
// Why: connect + listSessions share the caller's one absolute deadline so a
// wedged handshake cannot burn the whole teardown budget before the list issues.
await this.ensureConnected(opts?.deadlineMs)
const result = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingDaemonRequestTimeoutMs(opts?.deadlineMs)
)
const admission = new PtyProcessListAdmission()
const processes: PtyProcessInfo[] = []
const aliveSessionIds = new Set<string>()
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
aliveSessionIds.add(session.sessionId)
this.recordSessionAuthority(session.sessionId, session.incarnationId)
const { worktreeId } = parsePtySessionId(session.sessionId)
processes.push(
admission.admit({
id: session.sessionId,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
...(this.ownerIdentity(session.incarnationId)
? { ownerIdentity: this.ownerIdentity(session.incarnationId) }
: {}),
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
...this.validatedAgentSessionOwners(session.agentSessionOwners)
})
)
}
// Why: hasPty reads activeSessionIds, and an exit missed while the socket
// was disconnected otherwise survives an authoritative inventory forever —
// defeating every absence proof built on the cache.
for (const id of preRequestActiveIds) {
if (!aliveSessionIds.has(id)) {
this.activeSessionIds.delete(id)
this.sessionIncarnations.delete(id)
this.sessionOwnerIdentities.delete(id)
}
}
this.publishAuditObservation(
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
)
return processes
} catch (error) {
const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing()
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
this.observeAuditFailure(
missingAuthenticatedToken
? 'token_missing_after_authenticated_disconnect'
: 'inventory_failed',
this.exactDaemonIncarnation,
[
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
],
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
)
throw error
}
}
protected validatedAgentSessionOwners(
owners: unknown
): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record<string, never> {
if (owners === undefined) {
return {}
}
if (
!Array.isArray(owners) ||
owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES ||
!owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live')
) {
throw new Error('agent_session_ownership_unknown')
}
return owners.length > 0
? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) }
: {}
}
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
// createdAt) per session for display; listProcesses drops that detail for
// the IPtyProvider contract. Keep both in parallel rather than widening
// the provider surface.
async listSessions(): Promise<SessionInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions
.filter((s) => s.isAlive)
.map((session) => ({
...session,
...this.validatedAgentSessionOwners(session.agentSessionOwners)
}))
}
getActiveSessionIds(): string[] {
return [...this.activeSessionIds]
}
// Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit
// for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever.
fanoutSyntheticExits(code: number): void {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.overlayDeadlineWarnedSessionIds.clear()
this.periodicDeadlineWarnedSessionIds.clear()
this.nonFinalAdmissionDeniedSessionIds.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.stopCheckpointTimer()
for (const id of ids) {
this.coldRestoreCache.delete(id)
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({
id,
code,
...(this.sessionIncarnations.get(id)
? { incarnationId: this.sessionIncarnations.get(id) }
: {})
})
}
this.sessionIncarnations.delete(id)
this.sessionOwnerIdentities.delete(id)
}
}
async getDefaultShell(): Promise<string> {
if (process.platform === 'win32') {
return process.env.COMSPEC || 'powershell.exe'
}
return process.env.SHELL || '/bin/zsh'
}
async getProfiles(): Promise<{ name: string; path: string }[]> {
if (process.platform === 'win32') {
return [
{ name: 'PowerShell', path: 'powershell.exe' },
{ name: 'Command Prompt', path: 'cmd.exe' }
]
}
const shells = ['/bin/zsh', '/bin/bash', '/bin/sh']
return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s }))
}
}
+282
View File
@@ -0,0 +1,282 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import type { ColdRestoreInfo } from './history-reader'
import { mintPtySessionId } from './pty-session-id'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import { supportsPtyStartupBarrier } from './shell-ready'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { resolveWslSessionContext } from './wsl-session-context'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
import { addWslEnvKeys } from '../wsl-env'
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false,
acceptsUnroutedExit: false
}
const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set()
operations.add(operation)
this.pendingSpawnOperationsBySessionId.set(sessionId, operations)
if (opts.agentSessionEnsure) {
this.pendingClaimSpawnOperations.add(operation)
}
const historyRecovery: HistoryRecoveryContext = {
freeze: null,
unreadableSessionId: null,
identityChanged: false
}
try {
return await this.withHistorySpawnLock(sessionId, () =>
this.withDaemonRetry(() =>
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
)
)
} finally {
if (historyRecovery.freeze) {
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
}
this.pendingClaimSpawnOperations.delete(operation)
operations.delete(operation)
if (operations.size === 0) {
this.pendingSpawnOperationsBySessionId.delete(sessionId)
}
}
}
protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
const wslContext = resolveWslSessionContext({
cwd: opts.cwd,
sessionId: opts.sessionId,
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})
if (
opts.attachOnly === true ||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
!opts.worktreeId ||
opts.historyIsolationEnabled !== true ||
(process.platform === 'win32' && !wslContext)
) {
return opts
}
const env = { ...opts.env }
const preferredShell = wslContext
? 'bash'
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
const shellPath = resolveUnixShellPath(preferredShell)
const historyArgs = [
env,
opts.worktreeId,
shellPath,
opts.cwd ?? resolveSafePtyDefaultCwd()
] as const
const result = wslContext
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
: injectHistoryEnv(...historyArgs)
if (wslContext) {
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
}
logHistoryInjection(opts.worktreeId, result)
return { ...opts, env }
}
protected async doSpawn(
opts: PtySpawnOptions,
operation: PendingDaemonSpawnOperation,
historyRecovery: HistoryRecoveryContext
): Promise<PtySpawnResult> {
if (
opts.agentSessionEnsure &&
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
) {
throw new Error('agent_session_claim_unavailable')
}
const requestedSessionId = opts.sessionId!
// Why: v30 daemons survive upgrades; reject their accidental create result before publication.
const attachOnly = opts.attachOnly === true
const emulateLegacyAttachOnly =
attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
let sessionId = requestedSessionId
let wslDistro = resolveWslSessionContext({
cwd: opts.cwd,
sessionId,
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})?.distro
let activeSpawnContext: DaemonPtySpawnContext | null = null
const freezeHistory = async (): Promise<void> => {
if (!this.historyManager) {
return
}
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
if (historyRecovery.freeze?.sessionId === recoverySessionId) {
return
}
if (historyRecovery.freeze) {
this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze)
}
historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId)
historyRecovery.unreadableSessionId = null
}
const detectColdRestore = async (options?: {
ignoreCleanEnd?: boolean
}): Promise<ColdRestoreInfo | null> => {
if (!this.historyReader) {
return null
}
await freezeHistory()
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro
const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, {
...options,
wslDistro: recoveryWslDistro
})
if (detection.status === 'unreadable') {
historyRecovery.unreadableSessionId = detection.sessionId
return null
}
const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null
if (detection.status === 'restored' && detection.hasUnreadableRecovery) {
historyRecovery.unreadableSessionId = detection.sessionId
}
if (!restoreInfo) {
return null
}
return {
...restoreInfo,
cwd:
normalizeWslColdRestoreCwd({
recoveredCwd: restoreInfo.cwd,
requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(),
wslDistro: recoveryWslDistro
}) ?? ''
}
}
if (this.killedSessionTombstones.has(sessionId)) {
throw new TerminalKilledError(sessionId)
}
if (opts.isNewSession) {
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
await this.replaceStaleBundleDaemonBeforeNewPty()
await this.replaceSeveredMacTccDaemonBeforeNewPty()
}
await this.ensureConnected()
// Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from
// a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop
// handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached.
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(sessionId, false)
}
// Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request.
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
const historyProbe = opts.attachOnly
? undefined
: this.historyReader?.probeRestorableHistory(sessionId)
if (historyProbe && historyProbe.status !== 'none') {
if ((await this.getAppliedSize(sessionId)) !== null) {
restoreSkippedForLiveSession = true
if (this.historyManager && !this.historyManager.hasWriter(sessionId)) {
await detectColdRestore()
restoreInfo = null
}
} else {
restoreInfo = await detectColdRestore()
}
}
let effectiveCwd = restoreInfo?.cwd ?? opts.cwd
let effectiveCols = restoreInfo?.cols ?? opts.cols
let effectiveRows = restoreInfo?.rows ?? opts.rows
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
const isCodexStartupCommand =
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex'
const shouldWaitForShellReady =
isCodexStartupCommand &&
shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
})
const shellReadyTimeoutMs =
shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const context: DaemonPtySpawnContext = {
opts,
operation,
historyRecovery,
requestedSessionId,
attachOnly,
emulateLegacyAttachOnly,
sessionId,
wslDistro,
restoreInfo,
restoreSkippedForLiveSession,
effectiveCwd,
effectiveCols,
effectiveRows,
shellReadySupported,
shellReadyTimeoutMs,
historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null,
detectColdRestore
}
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
return this.finishSpawn(context, result)
}
protected resultForExitBeforeSpawnReply(
sessionId: string,
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null {
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some(
(exit) =>
!(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
)
if (!matchingExit) {
return null
}
// Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state.
const exitedResult: PtySpawnResult = {
id: sessionId,
exitedBeforeSpawnReply: true,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
return exitedResult
}
didExitBeforeSpawnReply(result: PtySpawnResult): boolean {
return result.exitedBeforeSpawnReply === true
}
}
@@ -0,0 +1,13 @@
import type { TerminalOwnerIdentity } from '../../shared/terminal-owner-identity'
import type { PtySpawnResult } from '../providers/types'
import type { CreateOrAttachResult } from './types'
export function daemonPtySpawnIdentityFields(
result: Pick<CreateOrAttachResult, 'incarnationId'>,
ownerIdentity: TerminalOwnerIdentity | undefined
): Pick<PtySpawnResult, 'incarnationId' | 'ownerIdentity'> {
return {
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(ownerIdentity ? { ownerIdentity } : {})
}
}
@@ -0,0 +1,118 @@
import { TerminalAttachCanceledError } from './types'
export type PendingPtySpawnPreparation = {
canceled: boolean
// Why: `canceled` is only polled between spawn steps; the signal is what
// actually interrupts an in-progress cwd probe on a dead share.
readonly controller: AbortController
cancelTimer?: ReturnType<typeof setTimeout>
// Why: preparations are keyed by sessionId, but a control-socket close must
// cancel only the disconnecting client's preps, not another client's (F4).
clientId: string
requestId: string
}
export class DaemonPtySpawnPreparations {
private readonly pending = new Map<string, Set<PendingPtySpawnPreparation>>()
private readonly cancellationByPreparation = new WeakMap<
PendingPtySpawnPreparation,
{ promise: Promise<void>; resolve: () => void }
>()
constructor(private readonly preparePtySpawn: () => Promise<void>) {}
register(
sessionId: string,
clientId: string,
requestId: string,
cancelAfterMs: unknown
): PendingPtySpawnPreparation {
const preparation: PendingPtySpawnPreparation = {
canceled: false,
controller: new AbortController(),
clientId,
requestId
}
this.cancellationByPreparation.set(preparation, Promise.withResolvers<void>())
if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) {
preparation.cancelTimer = setTimeout(
() => this.cancelPreparation(preparation),
Math.min(Number(cancelAfterMs), 300_000)
)
preparation.cancelTimer.unref()
}
const pendingForSession = this.pending.get(sessionId) ?? new Set()
pendingForSession.add(preparation)
this.pending.set(sessionId, pendingForSession)
return preparation
}
async prepareUnlessCanceled(
sessionId: string,
preparation: PendingPtySpawnPreparation
): Promise<void> {
// Race cancellation against preflight so shutdown/disconnect can return the
// protocol cancellation error before tearing down the client transport.
const cancellation = this.cancellationByPreparation.get(preparation)
const preparationTask = cancellation
? Promise.race([this.preparePtySpawn(), cancellation.promise])
: this.preparePtySpawn()
await preparationTask
if (preparation.canceled) {
throw new TerminalAttachCanceledError(sessionId)
}
}
finish(sessionId: string, preparation: PendingPtySpawnPreparation): void {
if (preparation.cancelTimer) {
clearTimeout(preparation.cancelTimer)
}
this.cancellationByPreparation.delete(preparation)
const pendingForSession = this.pending.get(sessionId)
pendingForSession?.delete(preparation)
if (pendingForSession?.size === 0) {
this.pending.delete(sessionId)
}
}
cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean {
const pendingForSession = this.pending.get(sessionId)
if (!pendingForSession) {
return false
}
let canceled = false
for (const preparation of pendingForSession) {
if (
request &&
(preparation.clientId !== request.clientId ||
(request.requestId !== undefined && preparation.requestId !== request.requestId))
) {
continue
}
this.cancelPreparation(preparation)
canceled = true
}
return canceled
}
cancelForClient(clientId: string): void {
for (const pendingForSession of this.pending.values()) {
for (const preparation of pendingForSession) {
if (preparation.clientId === clientId) {
this.cancelPreparation(preparation)
}
}
}
}
private cancelPreparation(preparation: PendingPtySpawnPreparation): void {
preparation.canceled = true
preparation.controller.abort()
this.cancellationByPreparation.get(preparation)?.resolve()
}
cancelAll(): void {
for (const sessionId of this.pending.keys()) {
this.cancel(sessionId)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
import type { ColdRestorePayload } from './cold-restore-payload-cache'
import type { DaemonAuditObservation } from './daemon-audit-classifier'
import {
DaemonPtyRuntimeState,
type HistoryRecoveryContext,
type PendingDaemonSpawnOperation,
type SnapshotCheckpointResult
} from './daemon-pty-runtime-state'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import type { ColdRestoreInfo } from './history-reader'
import { NdjsonLineTooLongError } from './ndjson'
import {
iterateTerminalHistorySeedChunks,
measureTerminalHistorySeed,
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
} from './terminal-history-seed-chunks'
import type { CreateOrAttachResult, GetSnapshotResult } from './types'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
export type DaemonPtySpawnContext = {
opts: PtySpawnOptions
operation: PendingDaemonSpawnOperation
historyRecovery: HistoryRecoveryContext
requestedSessionId: string
attachOnly: boolean
emulateLegacyAttachOnly: boolean
sessionId: string
wslDistro: string | undefined
restoreInfo: ColdRestoreInfo | null
restoreSkippedForLiveSession: boolean
effectiveCwd: string | undefined
effectiveCols: number
effectiveRows: number
shellReadySupported: boolean
shellReadyTimeoutMs: number | undefined
historySeedSegments: readonly string[] | null
detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise<ColdRestoreInfo | null>
}
export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
protected abstract takeSnapshotAndCheckpoint(
sessionId: string,
opts: {
teardown: boolean
forceLiveSnapshot?: boolean
requireContinuityProof?: boolean
}
): Promise<SnapshotCheckpointResult>
protected abstract markSessionDirty(sessionId: string): void
protected abstract writeSessionCheckpoint(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'>
protected abstract releasePendingRespawnAdoptionLease(): void
protected abstract setupEventRouting(): void
protected abstract scheduleCheckpointTimer(): void
protected abstract stopCheckpointTimer(): void
protected abstract stopCheckpointTimerIfIdle(): void
protected abstract recordAuthenticatedIdentity(): void
protected abstract runExclusiveCheckpoint(
operation: () => Promise<void>,
options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number }
): Promise<boolean>
protected abstract checkpointAllSessions(): Promise<void>
protected abstract ensureConnected(deadlineMs?: number): Promise<void>
protected abstract withHistorySpawnLock<T>(
sessionId: string,
operation: () => Promise<T>
): Promise<T>
protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void
protected abstract reconnectAfterWriteFailure(): void
protected abstract checkpointSessions(
sessionIds: Iterable<string>,
opts?: { final?: boolean; teardown?: boolean }
): Promise<Set<string>>
protected abstract publishAuditObservation(observation: DaemonAuditObservation): void
protected abstract isRetiredEndpointTokenMissing(): boolean
protected abstract withDaemonRetry<T>(fn: () => Promise<T>): Promise<T>
protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void>
protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise<void>
protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void>
abstract setPtyBackgrounded(id: string, background: boolean): void
abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null>
protected abstract resultForExitBeforeSpawnReply(
sessionId: string,
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null
protected abstract buildColdRestorePayload(
restoreInfo: ColdRestoreInfo
): ColdRestorePayload | null
protected abstract overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>>
protected async createOrAttachSpawn(
context: DaemonPtySpawnContext,
historySeedSegments: readonly string[] | null
): Promise<CreateOrAttachResult> {
const requestCreateOrAttach = async (
historySeed: string | undefined,
historySeedTransferId: string | undefined
) => {
const { opts } = context
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
const payload = {
sessionId: context.sessionId,
cols: context.effectiveCols,
rows: context.effectiveRows,
cwd: context.attachOnly ? undefined : context.effectiveCwd,
env: context.attachOnly ? undefined : opts.env,
envToDelete: context.attachOnly ? undefined : opts.envToDelete,
command: context.attachOnly ? undefined : opts.command,
startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery,
launchAgent: context.attachOnly ? undefined : opts.launchAgent,
...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}),
...(context.attachOnly &&
opts.expectedIncarnationIsAuthoritative === true &&
opts.expectedIncarnationId !== undefined
? { expectedIncarnationId: opts.expectedIncarnationId }
: {}),
shellOverride: context.attachOnly ? undefined : opts.shellOverride,
terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: context.attachOnly
? undefined
: opts.terminalWindowsPowerShellImplementation,
shellReadySupported: context.attachOnly ? false : context.shellReadySupported,
...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: context.shellReadyTimeoutMs }
: {}),
...(historySeed ? { historySeed } : {}),
...(historySeedTransferId ? { historySeedTransferId } : {}),
...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress
? { startupIngress: opts.startupIngress }
: {}),
...(!context.attachOnly && opts.agentSessionEnsure
? { agentSessionEnsure: opts.agentSessionEnsure }
: {})
}
context.operation.acceptsUnroutedExit = !context.attachOnly
try {
return await (opts.signal
? this.client.request<CreateOrAttachResult>(
'createOrAttach',
payload,
undefined,
opts.signal
)
: this.client.request<CreateOrAttachResult>('createOrAttach', payload))
} finally {
context.operation.acceptsUnroutedExit = false
}
}
let historySeedUnavailable = false
const deliverSeedAndCreate = async (): Promise<CreateOrAttachResult> => {
if (!historySeedSegments || historySeedSegments.length === 0) {
return requestCreateOrAttach(undefined, undefined)
}
const metrics = measureTerminalHistorySeed(historySeedSegments)
if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) {
try {
return await requestCreateOrAttach(historySeedSegments.join(''), undefined)
} catch (error) {
if (!(error instanceof NdjsonLineTooLongError)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
}
if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) {
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
let transferId: string | undefined
try {
const started = await this.client.request<{ transferId: string }>(
'startHistorySeedTransfer',
metrics
)
transferId = started.transferId
let index = 0
for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) {
await this.client.request('appendHistorySeedTransfer', { transferId, index, data })
index += 1
}
await this.client.request('finishHistorySeedTransfer', { transferId })
} catch (error) {
if (transferId) {
await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {})
}
if (isDaemonGoneError(error)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
return requestCreateOrAttach(undefined, transferId)
}
const result = await deliverSeedAndCreate()
return historySeedUnavailable && result.historySeeded === undefined
? { ...result, historySeeded: false }
: result
}
}
+329
View File
@@ -0,0 +1,329 @@
import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze'
import { daemonPtySpawnIdentityFields } from './daemon-pty-spawn-identity-fields'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { SessionNotFoundError, type CreateOrAttachResult } from './types'
import type { PtySpawnResult } from '../providers/types'
export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
protected async finishSpawn(
context: DaemonPtySpawnContext,
initialResult: CreateOrAttachResult
): Promise<PtySpawnResult> {
const {
opts,
operation,
historyRecovery,
requestedSessionId,
emulateLegacyAttachOnly,
restoreSkippedForLiveSession,
detectColdRestore
} = context
let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context
const createOrAttach = (historySeedSegments: readonly string[] | null) => {
Object.assign(context, {
sessionId,
effectiveCwd,
effectiveCols,
effectiveRows
})
return this.createOrAttachSpawn(context, historySeedSegments)
}
let result = initialResult
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
const requestedSessionId = sessionId
if (
opts.agentSessionEnsure &&
!isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure)
) {
// Why: a claim-incapable owner may already have spawned before returning
// a malformed response; retire only this requested session before failing closed.
await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {})
throw new Error('agent_session_claim_unavailable')
}
sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId
context.sessionId = sessionId
if (requestedSessionId === sessionId) {
return
}
if (historyRecovery.freeze) {
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
historyRecovery.freeze = null
}
historyRecovery.unreadableSessionId = null
historyRecovery.identityChanged = true
restoreInfo = null
historySeedSegments = null
}
if (emulateLegacyAttachOnly && result.isNew) {
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId: requestedSessionId, immediate: true })
throw new SessionNotFoundError(requestedSessionId)
}
await adoptSpawnResultSession(result)
// Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner.
this.clearSessionAwaitingDaemonRecovery(requestedSessionId)
this.clearSessionAwaitingDaemonRecovery(sessionId)
const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedResult) {
return exitedResult
}
if (
opts.attachOnly &&
opts.expectedIncarnationIsAuthoritative &&
(!result.incarnationId || result.incarnationId !== opts.expectedIncarnationId)
) {
throw new TerminalSessionOwnerUnverifiedError(sessionId)
}
this.recordSessionAuthority(sessionId, result.incarnationId)
const claimResult = (): Pick<PtySpawnResult, 'agentSessionEnsure'> | Record<string, never> =>
result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
wslDistro = providerWslDistro ?? undefined
context.wslDistro = wslDistro
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
} else if (providerWslDistro === null || result.isNew) {
this.wslDistrosBySessionId.delete(sessionId)
}
const launchIdentity = (): { launchAgent?: NonNullable<typeof result.launchAgent> } =>
result.launchAgent ? { launchAgent: result.launchAgent } : {}
if (effectiveCwd) {
this.initialCwds.set(sessionId, effectiveCwd)
}
// Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor.
let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
// Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data.
const cachedRestore = this.coldRestoreCache.get(sessionId)
if (cachedRestore) {
// Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal.
this.activeSessionIds.add(sessionId)
if (this.historyManager && !historyRecovery.identityChanged) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
if (historyRecovery.unreadableSessionId === sessionId) {
this.historyManager.suspendSession(sessionId, recoveryFreeze)
} else {
this.historyManager.reopenSession(sessionId, recoveryFreeze)
}
}
return {
id: sessionId,
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
pid,
...claimResult(),
...launchIdentity(),
coldRestore: cachedRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
if (result.incarnationId) {
operation.ignoredExitIncarnationIds.add(result.incarnationId)
}
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId, immediate: true })
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
effectiveRows = restoreInfo.rows
result = await createOrAttach(historySeedSegments)
await adoptSpawnResultSession(result)
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedRetryResult) {
return exitedRetryResult
}
this.recordSessionAuthority(sessionId, result.incarnationId)
providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
wslDistro = providerWslDistro ?? undefined
context.wslDistro = wslDistro
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
} else if (providerWslDistro === null || result.isNew) {
this.wslDistrosBySessionId.delete(sessionId)
}
pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
this.initialCwds.set(sessionId, effectiveCwd)
}
} else if (
!historyRecovery.identityChanged &&
!result.isNew &&
result.historySeeded === false
) {
restoreInfo = await detectColdRestore()
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
}
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceFromCreateOrAttach(result)
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
const coldRestore = this.buildColdRestorePayload(restoreInfo)
const canReanchorHistory =
!historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
if (this.historyManager && !historyRecovery.identityChanged) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
if (historyRecovery.unreadableSessionId === sessionId) {
await this.historyManager.openSession(sessionId, {
cwd: effectiveCwd ?? '',
cols: effectiveCols,
rows: effectiveRows,
...(recoveryFreeze ? { recoveryFreeze } : {}),
quarantineUnreadableRecovery: true
})
if (this.historyManager.hasWriter(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
} else if (canReanchorHistory) {
this.historyManager.registerWriter(sessionId, recoveryFreeze)
this.sessionsNeedingFullCheckpoint.add(sessionId)
// Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor.
this.lastFullCheckpointAt.delete(sessionId)
} else {
// Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent.
this.historyManager.suspendSession(sessionId, recoveryFreeze)
}
}
if (coldRestore) {
this.coldRestoreCache.set(sessionId, coldRestore)
return {
id: sessionId,
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
pid,
...claimResult(),
...launchIdentity(),
coldRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
return {
id: sessionId,
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {})
}
}
if (this.historyManager && !historyRecovery.identityChanged && result.isNew) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
await this.historyManager.openSession(sessionId, {
cwd: effectiveCwd ?? '',
cols: effectiveCols,
rows: effectiveRows,
...(recoveryFreeze ? { recoveryFreeze } : {}),
...(historyRecovery.unreadableSessionId === sessionId
? { quarantineUnreadableRecovery: true }
: {})
})
} else if (
this.historyManager &&
!historyRecovery.identityChanged &&
(result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId)
) {
// Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart.
this.historyManager.suspendSession(
sessionId,
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
)
} else if (this.historyManager && !historyRecovery.identityChanged) {
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
this.historyManager.registerWriter(
sessionId,
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
)
if (!wasAlreadyManaged) {
// Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
}
const isReattach = !result.isNew
if (!isReattach || !result.snapshot) {
return {
id: sessionId,
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
}
const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot)
const reattachProviderSequence =
typeof reattachSnapshot.outputSequence === 'number'
? { value: reattachSnapshot.outputSequence, generation: 'continued' as const }
: providerSequence
const isAltScreen = reattachSnapshot.modes.alternateScreen
const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences
const snapshotFrame = reattachSnapshot.snapshotAnsi
const snapshotPayload = snapshotPrefix + snapshotFrame
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
// Why known `0` is no longer dropped: the pane tracker must be able to tell
// "the app negotiated nothing" from "this reattach proved nothing".
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
reattachSnapshot.modes.kittyKeyboardFlags
)
return {
id: sessionId,
...daemonPtySpawnIdentityFields(result, this.ownerIdentity(result.incarnationId)),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
snapshot: snapshotPayload,
snapshotCols: reattachSnapshot.cols,
snapshotRows: reattachSnapshot.rows,
// Why only for an alt frame: normal history remains safe to replay at its capture grid.
...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi
? {
snapshotPrefixAnsi: snapshotPrefix,
snapshotFrameAnsi: snapshotFrame,
snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi
}
: {}),
...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}),
...(kittyKeyboardFlags !== undefined
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
isReattach: true,
isAlternateScreen: isAltScreen,
// Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch.
...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}),
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
...(reattachSnapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi }
: {})
}
}
}
@@ -0,0 +1,25 @@
export function remainingDaemonRequestTimeoutMs(
deadlineMs: number | undefined
): number | undefined {
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
}
// The caller's wait is bounded without cancelling durable work, which keeps running and committing.
export async function awaitDaemonWorkWithinCallerDeadline(
work: Promise<void>,
deadlineMs: number
): Promise<boolean> {
void work.catch(() => {})
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
work.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now()))
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
}
+291
View File
@@ -0,0 +1,291 @@
import { performance } from 'node:perf_hooks'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections'
import type { DaemonFileLog } from './daemon-file-log'
import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations'
import type { DaemonServerLifecycle } from './daemon-server-lifecycle'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { TerminalHost } from './terminal-host'
import { SessionNotFoundError, type DaemonRequest } from './types'
type DaemonRequestRouterOptions = {
host: TerminalHost
connections: DaemonClientConnections
lifecycle: DaemonServerLifecycle
admission: DaemonTerminalAdmission
preparations: DaemonPtySpawnPreparations
attachments: DaemonSessionAttachments
historySeedTransfers: TerminalHistorySeedTransferRegistry
sessionBackgroundRouting: DaemonSessionBackgroundRouting
streamDataBatcher: DaemonStreamDataBatcher
ptySpawnHealthCheck: () => Promise<void>
log: DaemonFileLog
}
export class DaemonRequestRouter {
constructor(private readonly options: DaemonRequestRouterOptions) {}
async route(clientId: string, request: DaemonRequest): Promise<unknown> {
const client = this.options.connections.get(clientId)
switch (request.type) {
case 'startHistorySeedTransfer': {
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
throw new Error('Daemon client connection is incomplete; reconnect')
}
return {
transferId: this.options.historySeedTransfers.start(clientId, request.payload)
}
}
case 'appendHistorySeedTransfer':
this.options.historySeedTransfers.append(
clientId,
request.payload.transferId,
request.payload.index,
request.payload.data
)
return {}
case 'finishHistorySeedTransfer':
this.options.historySeedTransfers.finish(clientId, request.payload.transferId)
return {}
case 'abortHistorySeedTransfer':
this.options.historySeedTransfers.abort(clientId, request.payload.transferId)
return {}
case 'createOrAttach':
return this.options.admission.createOrAttach(clientId, request)
case 'cancelCreateOrAttach':
return {
canceled: this.options.preparations.cancel(request.payload.sessionId, {
clientId,
...(typeof request.payload.requestId === 'string'
? { requestId: request.payload.requestId }
: {})
})
}
case 'closeStartupQueryAuthority':
return {
appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId)
}
case 'write':
return this.write(
client,
request.payload.sessionId,
request.payload.data,
request.payload.expectedIncarnationId
)
case 'resize':
return this.resize(
client,
request.payload.sessionId,
request.payload.cols,
request.payload.rows,
request.payload.expectedIncarnationId
)
case 'pausePty':
this.options.host.pauseProducer(request.payload.sessionId)
return {}
case 'resumePty':
this.options.host.resumeProducer(request.payload.sessionId)
return {}
case 'setSessionBackground':
return this.options.sessionBackgroundRouting.setBackground(
request.payload.sessionId,
request.payload.background === true
)
case 'kill':
return this.kill(clientId, request.payload.sessionId, request.payload.immediate)
case 'signal':
this.options.host.signal(request.payload.sessionId, request.payload.signal)
return {}
case 'detach':
this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId)
this.options.log.log('session-detached', { sessionId: request.payload.sessionId })
return {}
case 'getCwd':
return { cwd: await this.options.host.getCwd(request.payload.sessionId) }
case 'getForegroundProcess':
return {
foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId)
}
case 'inspectProcess':
return this.options.host.inspectProcess(request.payload.sessionId)
case 'confirmForegroundProcess':
return {
foregroundProcess: await this.options.host.confirmForegroundProcess(
request.payload.sessionId
)
}
case 'clearScrollback':
this.options.host.clearScrollback(request.payload.sessionId)
return {}
case 'listSessions':
return { sessions: this.options.host.listSessions() }
case 'shutdownIfIdle':
return this.shutdownIfIdle(clientId, request.id)
case 'getSnapshot':
return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows)
case 'getSize':
return { size: this.options.host.getAppliedSize(request.payload.sessionId) }
case 'takePendingOutput':
return this.options.host.takePendingOutput(
request.payload.sessionId,
request.payload.includeSnapshot === true,
{ teardownSnapshot: request.payload.teardownSnapshot === true }
)
case 'ping':
return { pong: true }
case 'systemResolverHealth':
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.options.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
return this.shutdown(clientId, request.id, request.payload.killSessions)
}
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
private write(
client: ConnectedDaemonClient | undefined,
sessionId: string,
data: string,
expectedIncarnationId?: string
): Record<string, never> {
try {
this.options.attachments.recordInput(sessionId)
this.options.host.write(sessionId, data, expectedIncarnationId)
} catch (error) {
this.options.attachments.clearInput(sessionId)
if (error instanceof SessionNotFoundError) {
this.sendExitEvent(client, sessionId, -1, expectedIncarnationId)
}
throw error
}
return {}
}
private resize(
client: ConnectedDaemonClient | undefined,
sessionId: string,
cols: number,
rows: number,
expectedIncarnationId?: string
): Record<string, never> {
try {
this.options.host.resize(sessionId, cols, rows, expectedIncarnationId)
} catch (error) {
if (error instanceof SessionNotFoundError) {
this.sendExitEvent(client, sessionId, -1, expectedIncarnationId)
}
throw error
}
return {}
}
private async kill(
clientId: string,
sessionId: string,
immediate: boolean | undefined
): Promise<Record<string, never>> {
const canceledPendingSpawn = this.options.preparations.cancel(sessionId)
this.options.attachments.clearInput(sessionId)
const attribution = { sessionId, immediate: immediate === true, clientId }
try {
await this.options.host.kill(sessionId, { immediate })
} catch (error) {
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
this.options.log.log('session-kill-failed', attribution)
throw error
}
}
this.options.log.log('session-killed', attribution)
return {}
}
private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } {
const client = this.options.connections.get(clientId)
const retiring =
client !== undefined &&
client.streamSocket !== null &&
this.options.connections.size === 1 &&
this.options.admission.inFlight === 0 &&
this.options.host.listSessions().length === 0 &&
this.options.connections.hasOnlyTransportsFor(client)
if (retiring) {
this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket)
}
return { retiring }
}
private getSnapshot(sessionId: string, requestedRows: unknown): unknown {
const startedAt = performance.now()
const scrollbackRows =
typeof requestedRows === 'number' && Number.isFinite(requestedRows)
? Math.max(0, Math.min(50_000, Math.floor(requestedRows)))
: undefined
const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows })
const snapshotMs = performance.now() - startedAt
if (snapshotMs >= 25) {
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
sessionIdSuffix: sessionId.slice(-10),
snapshotMs: Math.round(snapshotMs)
})
}
return { snapshot }
}
private async shutdown(
clientId: string,
requestId: string,
killSessions: boolean
): Promise<Record<string, never>> {
this.options.log.log('shutdown', {
reason: 'rpc',
killSessions: killSessions === true
})
const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence()
if (killSessions) {
try {
await this.options.host.dispose()
} catch (error) {
this.options.log.log('shutdown-dispose-failed', {
error: error instanceof Error ? error.message : String(error)
})
}
}
const controlSocket = this.options.connections.get(clientId)?.controlSocket
if (controlSocket) {
this.options.lifecycle.deferRpcShutdownUntilReply(
clientId,
requestId,
controlSocket,
serverClose
)
} else {
this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose)
}
return {}
}
private sendExitEvent(
client: ConnectedDaemonClient | undefined,
sessionId: string,
code: number,
incarnationId?: string
): void {
if (!client?.streamSocket) {
return
}
this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
type: 'event',
event: 'exit',
sessionId,
payload: { code, ...(incarnationId ? { incarnationId } : {}) }
})
this.options.streamDataBatcher.flush(client.clientId)
}
}
@@ -6,6 +6,12 @@ import { DaemonClient } from './client'
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
type DaemonServerInternals = {
preparations: {
pending: Map<string, Set<{ canceled: boolean }>>
}
host: { listSessions: () => unknown[] }
}
describe('daemon async spawn cancellation', () => {
let directory: string
@@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => {
await spawnStarted
client.disconnect()
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean }>>
host: { listSessions: () => unknown[] }
}
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() =>
expect(
[...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled
).toBe(true)
expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe(
true
)
)
releaseSpawn()
await create
@@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => {
30_000,
abort.signal
)
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
}
const daemon = server as unknown as DaemonServerInternals
// Attach-only used to register nothing, so the daemon could not match the
// cancel and the client dropped its only timeout.
await vi.waitFor(() =>
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
)
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
abort.abort()
await expect(attach).rejects.toThrow('client_disconnected')
@@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => {
abort.signal
)
await spawnStarted
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
}
await vi.waitFor(() =>
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
)
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
abort.abort()
await expect(second).rejects.toThrow('client_disconnected')
@@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = {
createOrAttach: (options: CreateOrAttachOptions) => Promise<CreateOrAttachResult>
sessions: Map<string, { hasAttachedClients: boolean }>
}
clients: Map<string, { streamSocket: Socket | null }>
streamClientIdBySessionId: Map<string, string>
attachTokenBySessionId: Map<string, symbol>
connections: {
clients: Map<string, { streamSocket: Socket | null }>
}
attachments: {
clientIdBySessionId: Map<string, string>
tokenBySessionId: Map<string, symbol>
}
}
describe('DaemonServer attachment lifecycle', () => {
@@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => {
await createSession('transport-owned-session')
const daemon = server as unknown as DaemonAttachmentPrivate
const detachClients = vi.spyOn(daemon.host, 'detachClients')
const streamSocket = [...daemon.clients.values()][0]?.streamSocket
const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket
streamSocket?.destroy()
@@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => {
{ sessionId: 'transport-owned-session', token: expect.any(Symbol) }
])
expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false)
})
it('parks only the requesting client attachment on explicit detach', async () => {
@@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => {
expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol))
expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false)
})
it('parks an attachment completed after its transport already closed', async () => {
@@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => {
await created
client.disconnect()
await vi.waitFor(() => expect(daemon.clients.size).toBe(0))
await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0))
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true)
finishRequest()
@@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => {
await vi.waitFor(() =>
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false)
)
expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false)
})
})
@@ -1,14 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs'
import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type Server } from 'node:net'
import { connect, createServer, type Server } from 'node:net'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import { DaemonClient } from './client'
import { isDaemonGoneError } from './daemon-pty-adapter'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import { encodeNdjson } from './ndjson'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session-subprocess-handle'
import { PROTOCOL_VERSION } from './types'
import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness'
// A killed process must actually report its exit: teardown waits
@@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle {
dispose() {}
}
}
type DaemonServerInternals = {
endpoint: {
hasLostOwnership(): boolean
requestRetirementForLoss(): void
checkOwnership(): void
ownershipLossStreak: number
ownershipLost: boolean
}
lifecycle: {
retirementRequested: boolean
state: string
server: Server | null
}
connections: { clients: Map<string, ConnectedDaemonClient> }
}
describe('daemon server error handling', () => {
let dir: string
@@ -88,8 +106,8 @@ describe('daemon server error handling', () => {
).rejects.toThrow(/no longer owns its endpoint/)
// And it stands down rather than lingering as an unreachable host.
const daemon = server as unknown as { retirementRequested: boolean }
expect(daemon.retirementRequested).toBe(true)
const daemon = server as unknown as DaemonServerInternals
expect(daemon.lifecycle.retirementRequested).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -108,17 +126,14 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as {
hasLostEndpointOwnership: () => boolean
retirementRequested: boolean
}
const daemon = server as unknown as DaemonServerInternals
try {
// Drop search permission on the directory so the ownership stat fails EACCES.
chmodSync(dir, 0o600)
expect(daemon.hasLostEndpointOwnership()).toBe(false)
expect(daemon.retirementRequested).toBe(false)
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
expect(daemon.lifecycle.retirementRequested).toBe(false)
} finally {
chmodSync(dir, 0o700)
}
@@ -150,8 +165,8 @@ describe('daemon server error handling', () => {
linkSync(usurperBind, socketPath)
// The guard is still armed here — no creation has been refused yet.
const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean }
expect(daemon.hasLostEndpointOwnership()).toBe(true)
const daemon = server as unknown as DaemonServerInternals
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
await expect(
client.request('createOrAttach', {
@@ -198,14 +213,11 @@ describe('daemon server error handling', () => {
).rejects.toThrow(/no longer owns its endpoint/)
// Simulate a late client completing its handshake and clearing pending retirement.
const daemon = server as unknown as {
retirementRequested: boolean
hasLostEndpointOwnership: () => boolean
}
daemon.retirementRequested = false
const daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.retirementRequested = false
// The loss must still be remembered, so creation stays closed.
expect(daemon.hasLostEndpointOwnership()).toBe(true)
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
await expect(
client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 })
).rejects.toThrow(/no longer owns its endpoint/)
@@ -248,16 +260,13 @@ describe('daemon server error handling', () => {
// Losing the endpoint with nothing left to drain must stand the daemon down, even
// though this client is still connected.
const daemon = server as unknown as {
requestRetirementForLostEndpoint: () => void
idleShutdownState: string
}
expect(daemon.idleShutdownState).toBe('running')
daemon.requestRetirementForLostEndpoint()
const daemon = server as unknown as DaemonServerInternals
expect(daemon.lifecycle.state).toBe('running')
daemon.endpoint.requestRetirementForLoss()
// Drained and unreachable: it must begin standing down rather than wait for this
// client, which can never make it routable again.
expect(daemon.idleShutdownState).not.toBe('running')
expect(daemon.lifecycle.state).not.toBe('running')
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -320,19 +329,15 @@ describe('daemon server error handling', () => {
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
const daemon = server as unknown as {
retirementRequested: boolean
endpointOwnershipLost: boolean
checkEndpointOwnership: () => void
}
const daemon = server as unknown as DaemonServerInternals
// Retirement is already pending for an unrelated reason.
daemon.retirementRequested = true
daemon.lifecycle.retirementRequested = true
daemon.checkEndpointOwnership()
daemon.checkEndpointOwnership()
daemon.endpoint.checkOwnership()
daemon.endpoint.checkOwnership()
// The loss must be recorded regardless, so a later hello cannot undo it.
expect(daemon.endpointOwnershipLost).toBe(true)
expect(daemon.endpoint.ownershipLost).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -352,12 +357,7 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as {
checkEndpointOwnership: () => void
hasLostEndpointOwnership: () => boolean
endpointOwnershipLost: boolean
endpointOwnershipLossStreak: number
}
const daemon = server as unknown as DaemonServerInternals
// A second name for our own socket inode, so ownership can be handed back.
const ourAlias = join(dir, '.ours')
linkSync(socketPath, ourAlias)
@@ -369,21 +369,21 @@ describe('daemon server error handling', () => {
// First loss.
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
daemon.checkEndpointOwnership()
expect(daemon.endpointOwnershipLossStreak).toBe(1)
expect(daemon.endpointOwnershipLost).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.endpoint.ownershipLossStreak).toBe(1)
expect(daemon.endpoint.ownershipLost).toBe(false)
// Ownership demonstrably returns, observed through the admission path.
unlinkSync(socketPath)
linkSync(ourAlias, socketPath)
expect(daemon.hasLostEndpointOwnership()).toBe(false)
expect(daemon.endpointOwnershipLossStreak).toBe(0)
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
expect(daemon.endpoint.ownershipLossStreak).toBe(0)
// A later isolated loss is confirmation #1, not #2, so it must not retire.
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
daemon.checkEndpointOwnership()
expect(daemon.endpointOwnershipLost).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.endpoint.ownershipLost).toBe(false)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -400,19 +400,21 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as { server: Server | null }
const daemon = server as unknown as DaemonServerInternals
expect(daemon.server?.listenerCount('error')).toBe(1)
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
// Twice on purpose: a one-shot listener survives the first error and dies on the second,
// so a single emit cannot tell a permanent handler from `once`.
expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow()
expect(() =>
daemon.server?.emit('error', new Error('EMFILE: accept failed again'))
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed'))
).not.toThrow()
expect(() =>
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again'))
).not.toThrow()
expect(warn).toHaveBeenCalledTimes(2)
expect(daemon.server?.listenerCount('error')).toBe(1)
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
} finally {
warn.mockRestore()
}
@@ -422,6 +424,44 @@ describe('daemon server error handling', () => {
expect(client.isConnected()).toBe(true)
})
it('rejects unsupported hello roles without replacing an authenticated stream', async () => {
const onAuthenticatedClientPair = vi.fn()
server = new DaemonServer({
socketPath,
tokenPath,
onAuthenticatedClientPair,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as DaemonServerInternals
const connectedClient = [...daemon.connections.clients.values()][0]
const originalStream = connectedClient.streamSocket
const unsupported = connect(socketPath)
unsupported.on('error', () => {})
await new Promise<void>((resolve) => unsupported.once('connect', resolve))
const response = new Promise<{ ok: boolean; error?: string }>((resolve) => {
unsupported.once('data', (data) => resolve(JSON.parse(data.toString())))
})
unsupported.write(
encodeNdjson({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8'),
clientId: connectedClient.clientId,
role: 'observer'
})
)
await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' })
unsupported.destroy()
expect(connectedClient.streamSocket).toBe(originalStream)
expect(connectedClient.authenticatedPairEstablished).toBe(true)
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
})
it.skipIf(process.platform === 'win32')(
'stands down instead of serving when the server errors while publication is in flight',
async () => {
@@ -443,14 +483,14 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
const started = server.start()
const daemon = server as unknown as { server: Server | null }
await vi.waitFor(() => expect(daemon.server?.listening).toBe(true))
daemon.server?.emit('error', new Error('injected accept failure'))
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true))
daemon.lifecycle.server?.emit('error', new Error('injected accept failure'))
await expect(started).rejects.toThrow('injected accept failure')
// Why wait: start() rejects the moment the error lands, while publication is still in
// flight. Reporting failure must end with the daemon actually not serving.
await vi.waitFor(() => expect(daemon.server).toBeNull())
await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull())
await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true)
}
)
@@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types'
type DaemonServerPrivate = {
host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void> }
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean; clientId: string }>>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
preparations: {
pending: Map<string, Set<{ canceled: boolean; clientId: string }>>
}
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
}
describe('daemon kill attribution', () => {
@@ -38,7 +42,7 @@ describe('daemon kill attribution', () => {
})
vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished)
const request = daemon.routeRequest('control-42', {
const request = daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -71,7 +75,7 @@ describe('daemon kill attribution', () => {
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused'))
await expect(
daemon.routeRequest('control-42', {
daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -103,10 +107,10 @@ describe('daemon kill attribution', () => {
controller: new AbortController(),
clientId: 'control-42'
}
daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation]))
daemon.preparations.pending.set('agent-session', new Set([pendingPreparation]))
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session'))
await daemon.routeRequest('control-42', {
await daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
+278
View File
@@ -0,0 +1,278 @@
import { createServer, type Server, type Socket } from 'node:net'
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
import type { DaemonFileLog } from './daemon-file-log'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
type PendingShutdownReply = { start: () => void }
type DaemonServerLifecycleOptions = {
protocolVersion: number
initialAdoptionTimeoutMs: number
clock: {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
endpoint: DaemonEndpointLifecycle
log: DaemonFileLog
isIdle: () => boolean
disposeResources: () => Promise<void>
onIdleShutdown: () => void
onRpcShutdown: () => void
}
export class DaemonServerLifecycle {
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
private server: Server | null = null
private startupFailure: Error | null = null
private state: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running'
private initialAdoptionTimer: unknown = null
private initialAdoptionDeadlineMs: number | null = null
private retirementRequested = false
private shutdownPromise: Promise<void> | null = null
private ordinaryShutdownServerClose: Promise<void> | null = null
private readonly pendingShutdownReplies = new Map<string, PendingShutdownReply>()
constructor(private readonly options: DaemonServerLifecycleOptions) {}
isAcceptingWork(): boolean {
return this.state === 'running'
}
async start(handleConnection: (socket: Socket) => void): Promise<void> {
return new Promise((resolve, reject) => {
this.server = createServer(handleConnection)
let startupSettled = false
const onServerError = (error: Error): void => {
if (startupSettled) {
this.options.log.log('server-error', { message: error.message })
console.warn(`[daemon] Socket server error: ${error.message}`)
return
}
startupSettled = true
this.startupFailure = error
reject(error)
}
this.server.on('error', onServerError)
const bindPath = this.options.endpoint.bindPath()
this.server.listen(bindPath, () => {
this.options.endpoint.secureBindPath(bindPath)
const abandonStartup = (error: unknown): void => {
startupSettled = true
const server = this.server
this.server = null
reject(error)
server?.close()
this.options.endpoint.abandonBindPath(bindPath)
}
void this.options.endpoint.publish(bindPath).then(() => {
if (this.startupFailure) {
this.options.endpoint.retireUnstarted()
this.cancelInitialAdoptionTimer()
abandonStartup(this.startupFailure)
return
}
if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
this.armInitialAdoptionTimeout()
}
this.options.endpoint.startOwnershipWatch()
startupSettled = true
resolve()
}, abandonStartup)
})
})
}
shutdown(): Promise<void> {
if (!this.shutdownPromise) {
const serverClose = this.beginOrdinaryShutdownFence()
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
}
return this.shutdownPromise
}
onConnectionAccepted(): void {
this.cancelInitialAdoptionTimer()
}
onAuthenticatedPair(): void {
this.initialAdoptionDeadlineMs = null
this.cancelInitialAdoptionTimer()
if (!this.options.endpoint.lost) {
this.retirementRequested = false
}
}
onLastAuthenticatedClientDisconnected(): void {
if (this.state === 'running') {
this.retirementRequested = true
}
}
onEndpointOwnershipLost(): void {
this.retirementRequested = true
this.reevaluateIdleShutdown()
}
reevaluateIdleShutdown(): void {
if (this.state !== 'running') {
return
}
if (this.retirementRequested) {
this.cancelInitialAdoptionTimer()
if (this.options.isIdle()) {
this.beginIdleShutdown()
}
return
}
if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) {
this.cancelInitialAdoptionTimer()
return
}
if (this.initialAdoptionTimer !== null) {
return
}
const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now())
if (remainingMs === 0) {
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
return
}
this.initialAdoptionTimer = this.options.clock.setTimeout(() => {
this.initialAdoptionTimer = null
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
}, remainingMs)
}
beginOrdinaryShutdownFence(): Promise<void> {
this.state = 'shutting-down'
this.cancelInitialAdoptionTimer()
this.ordinaryShutdownServerClose ??= this.beginServerClose()
return this.ordinaryShutdownServerClose
}
deferRpcShutdownUntilReply(
clientId: string,
requestId: string,
socket: Socket,
serverClose: Promise<void>
): void {
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
this.finishRpcShutdown(serverClose)
)
}
finishRpcShutdownWithoutReply(serverClose: Promise<void>): void {
this.shutdownPromise ??= this.finishRpcShutdown(serverClose)
}
retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void {
this.state = 'shutting-down'
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
const serverClose = this.beginServerClose()
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
this.finishIdleShutdown(serverClose)
)
}
startPendingShutdownReply(clientId: string, requestId: string): void {
this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start()
}
private armInitialAdoptionTimeout(): void {
this.initialAdoptionDeadlineMs =
this.options.clock.now() + this.options.initialAdoptionTimeoutMs
this.reevaluateIdleShutdown()
}
private cancelInitialAdoptionTimer(): void {
if (this.initialAdoptionTimer === null) {
return
}
this.options.clock.clearTimeout(this.initialAdoptionTimer)
this.initialAdoptionTimer = null
}
private beginIdleShutdown(): void {
this.initialAdoptionTimer = null
if (this.state !== 'running') {
return
}
this.state = 'idle-shutdown-pending'
if (!this.options.isIdle()) {
this.state = 'running'
this.reevaluateIdleShutdown()
return
}
this.state = 'shutting-down'
const serverClose = this.beginServerClose()
this.shutdownPromise = this.finishIdleShutdown(serverClose)
}
private async finishOrdinaryShutdown(serverClose: Promise<void>): Promise<void> {
this.options.endpoint.unlinkOwnedArtifacts()
await this.disposeResources()
await serverClose
}
private async finishRpcShutdown(serverClose: Promise<void>): Promise<void> {
await this.finishOrdinaryShutdown(serverClose)
this.options.onRpcShutdown()
}
private async finishIdleShutdown(serverClose: Promise<void>): Promise<void> {
this.options.endpoint.unlinkOwnedArtifacts()
await this.disposeResources()
await serverClose
this.options.onIdleShutdown()
}
private async disposeResources(): Promise<void> {
await this.options.disposeResources()
this.pendingShutdownReplies.clear()
}
private beginServerClose(): Promise<void> {
const server = this.server
this.server = null
if (!server) {
return Promise.resolve()
}
return new Promise<void>((resolve) => server.close(() => resolve()))
}
private deferShutdownUntilReply(
clientId: string,
requestId: string,
socket: Socket,
finish: () => Promise<void>
): void {
const key = this.shutdownReplyKey(clientId, requestId)
let started = false
let timer: ReturnType<typeof setTimeout>
const start = (): void => {
if (started) {
return
}
started = true
clearTimeout(timer)
socket.off('close', start)
socket.off('error', start)
this.pendingShutdownReplies.delete(key)
this.shutdownPromise ??= finish()
}
timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
timer.unref()
socket.once('close', start)
socket.once('error', start)
this.pendingShutdownReplies.set(key, { start })
}
private shutdownReplyKey(clientId: string, requestId: string): string {
return `${clientId}\u0000${requestId}`
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session-subprocess-handle'
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
startedAtMs?: number
publishEndpointOwnership?: () => void
entryPath?: string
appVersion?: string
spawnerExecPath?: string
protocolVersion?: number
onIdleShutdown?: () => void
onRpcShutdown?: () => void
initialAdoptionTestConfig?: {
timeoutMs: number
clock: {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
}
ptySpawnHealthCheck?: () => Promise<void>
preparePtySpawn?: () => Promise<void>
onPtySessionExit?: (sessionId: string) => void
onAuthenticatedClientPair?: () => void
log?: DaemonFileLog
spawnSubprocess: (opts: {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
command?: string
shellOverride?: string
isCanceled?: () => boolean
}) => SubprocessHandle | Promise<SubprocessHandle>
}
+27 -31
View File
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
@@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & {
}
type DaemonServerPrivate = {
server: Server | null
pendingPtySpawnPreparations: Map<string, Set<unknown>>
lifecycle: { server: Server | null }
preparations: { pending: Map<string, Set<unknown>> }
host: {
kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void>
dispose: () => Promise<void>
}
connections: { clients: Map<string, ConnectedDaemonClient> }
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
clients: Map<
string,
{
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
}
describe('DaemonServer', () => {
@@ -345,11 +341,11 @@ describe('DaemonServer', () => {
// Wait for the server to process the close (and cancel the prep) before
// releasing the preflight, else the resumed spawn races ahead of cancellation.
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0)
)
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).not.toHaveBeenCalled()
})
@@ -486,8 +482,8 @@ describe('DaemonServer', () => {
const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown)
let acknowledged = false
const routed = daemon
.routeRequest('client-1', {
const routed = daemon.requestRouter
.route('client-1', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -592,14 +588,14 @@ describe('DaemonServer', () => {
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -612,7 +608,7 @@ describe('DaemonServer', () => {
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"')
streamSocket.write.mockClear()
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-2',
type: 'write',
payload: { sessionId: 'test-session', data: 'x' }
@@ -649,14 +645,14 @@ describe('DaemonServer', () => {
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -707,13 +703,13 @@ describe('DaemonServer', () => {
writableLength: number
}
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -780,11 +776,11 @@ describe('DaemonServer', () => {
const control = await connectRawHello('control', 'raw-client')
const stream = await connectRawHello('stream', 'raw-client')
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
stream.destroy()
await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null)
await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null)
control.destroy()
})
@@ -801,7 +797,7 @@ describe('DaemonServer', () => {
const secondStream = await connectRawHello('stream', 'raw-client')
await waitFor(() => firstClosed)
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
secondStream.destroy()
control.destroy()
})
@@ -823,8 +819,8 @@ describe('DaemonServer', () => {
const secondControl = await connectRawHello('control', 'raw-client')
await waitFor(() => firstControlClosed && firstStreamClosed)
expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy()
expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull()
expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull()
secondControl.destroy()
})
@@ -838,7 +834,7 @@ describe('DaemonServer', () => {
})
await waitFor(() => closed || stream.destroyed)
expect(daemon.clients.has('missing-client')).toBe(false)
expect(daemon.connections.clients.has('missing-client')).toBe(false)
})
})
@@ -850,7 +846,7 @@ describe('DaemonServer', () => {
const daemon = server as unknown as DaemonServerPrivate & {
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
@@ -932,7 +928,7 @@ describe('DaemonServer', () => {
const c = await connectClient()
await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
await waitFor(() => daemon.server === null)
await waitFor(() => daemon.lifecycle.server === null)
// Why not existsSync: the dead entry remains for the next publisher to replace.
expect(await waitForEndpointUnreachable(socketPath)).toBe(true)
const late = new DaemonClient({ socketPath, tokenPath })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { performance } from 'node:perf_hooks'
import type { TerminalHost } from './terminal-host'
export class DaemonSessionAttachments {
private readonly clientIdBySessionId = new Map<string, string>()
private readonly tokenBySessionId = new Map<string, symbol>()
private readonly lastInputAtBySessionId = new Map<string, number>()
constructor(private readonly host: TerminalHost) {}
attach(sessionId: string, clientId: string, token: symbol): void {
this.clientIdBySessionId.set(sessionId, clientId)
this.tokenBySessionId.set(sessionId, token)
}
clientIdForSession(sessionId: string): string | undefined {
return this.clientIdBySessionId.get(sessionId)
}
recordInput(sessionId: string): void {
this.lastInputAtBySessionId.set(sessionId, performance.now())
}
lastInputAt(sessionId: string): number | undefined {
return this.lastInputAtBySessionId.get(sessionId)
}
clearInput(sessionId: string): void {
this.lastInputAtBySessionId.delete(sessionId)
}
release(sessionId: string): void {
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
this.lastInputAtBySessionId.delete(sessionId)
}
detachSessionForClient(sessionId: string, clientId: string): void {
if (this.clientIdBySessionId.get(sessionId) !== clientId) {
return
}
const token = this.tokenBySessionId.get(sessionId)
if (token) {
this.host.detach(sessionId, token)
}
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
}
detachClientSessions(clientId: string): void {
const attachments: { sessionId: string; token: symbol }[] = []
for (const [sessionId, attachedClientId] of this.clientIdBySessionId) {
if (attachedClientId !== clientId) {
continue
}
const token = this.tokenBySessionId.get(sessionId)
if (token) {
attachments.push({ sessionId, token })
}
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
}
if (attachments.length > 0) {
this.host.detachClients(attachments)
}
}
}
@@ -0,0 +1,55 @@
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { TerminalHost } from './terminal-host'
type DaemonSessionBackgroundRoutingOptions = {
host: TerminalHost
attachments: DaemonSessionAttachments
transientFactRelay: BackgroundTransientFactRelay
streamDataBatcher: DaemonStreamDataBatcher
}
export class DaemonSessionBackgroundRouting {
constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {}
setBackground(sessionId: string, background: boolean): Record<string, never> {
recordDaemonStreamBacklogEvent('setSessionBackground', {
sessionIdSuffix: sessionId.slice(-10),
background
})
const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background)
this.options.streamDataBatcher.refreshSessionDroppability(sessionId)
if (!changed) {
return {}
}
if (background) {
this.options.transientFactRelay.seedSessionScanState(
sessionId,
this.options.host.getPartialEscapeTailAnsi(sessionId)
)
}
const streamClientId = this.options.attachments.clientIdForSession(sessionId)
if (!streamClientId) {
return {}
}
const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId)
const scanSeedAnsi = background
? ''
: mode2031State.pendingSubscribe
? mode2031State.tail
: this.options.host.getPartialEscapeTailAnsi(sessionId)
this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId,
payload: {
background,
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
}
})
return {}
}
}
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { DaemonFileLog } from './daemon-file-log'
@@ -4,6 +4,7 @@ import type { Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
@@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & {
}
type DaemonLifecyclePrivate = {
clients: Map<
string,
{
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
>
host: {
getPartialEscapeTailAnsi(sessionId: string): string
connections: { clients: Map<string, ConnectedDaemonClient> }
host: { getPartialEscapeTailAnsi(sessionId: string): string }
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
streamDataBatcher: DaemonStreamDataBatcher
transientFactRelay: BackgroundTransientFactRelay
streamClientIdBySessionId: Map<string, string>
attachments: { clientIdBySessionId: Map<string, string> }
}
function createMockSubprocess(): MockSubprocess {
@@ -95,7 +88,7 @@ function addClient(
write: ReturnType<typeof vi.fn>
writableLength: number
}
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
@@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => {
server = harness.server
const { daemon } = harness
addClient(daemon)
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('')
const lifecycle: string[] = []
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
@@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => {
}
)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'background',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: true }
@@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => {
expect(lifecycle).toEqual(['refresh:true', 'marker:true'])
lifecycle.length = 0
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'duplicate-background',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: true }
@@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => {
expect(lifecycle).toEqual(['refresh:true'])
lifecycle.length = 0
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'foreground',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: false }
@@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => {
server = harness.server
const { daemon } = harness
addClient(daemon)
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
daemon.transientFactRelay.setSessionBackground('session-toggle', true)
daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?')
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?')
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'foreground',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: false }
@@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => {
const lifecycle: string[] = []
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
(sessionId) => {
lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`)
lifecycle.push(
`refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}`
)
}
)
vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation(
@@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => {
}
)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'attach',
type: 'createOrAttach',
payload: { sessionId: 'session-attach', cols: 80, rows: 24 }
@@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => {
addClient(daemon, 128 * 1024)
daemon.transientFactRelay.setSessionBackground('session-exit', true)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'attach',
type: 'createOrAttach',
payload: { sessionId: 'session-exit', cols: 80, rows: 24 }
@@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => {
payload: { code: 42 }
})
expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false)
expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false)
})
})
@@ -0,0 +1,216 @@
import { performance } from 'node:perf_hooks'
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { DaemonClientConnections } from './daemon-client-connections'
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import type { DaemonFileLog } from './daemon-file-log'
import type {
DaemonPtySpawnPreparations,
PendingPtySpawnPreparation
} from './daemon-pty-spawn-preparations'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import {
isAgentSessionExecutionClaim,
isAgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import { type DaemonRequest, TerminalAttachCanceledError } from './types'
type CreateOrAttachRequest = Extract<DaemonRequest, { type: 'createOrAttach' }>
type DaemonTerminalAdmissionOptions = {
host: TerminalHost
connections: DaemonClientConnections
endpoint: DaemonEndpointLifecycle
preparations: DaemonPtySpawnPreparations
attachments: DaemonSessionAttachments
historySeedTransfers: TerminalHistorySeedTransferRegistry
transientFactRelay: BackgroundTransientFactRelay
streamDataBatcher: DaemonStreamDataBatcher
log: DaemonFileLog
isAcceptingWork: () => boolean
requestEndpointRetirement: () => void
reevaluateIdleShutdown: () => void
}
export class DaemonTerminalAdmission {
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
private createOrAttachInFlight = 0
constructor(private readonly options: DaemonTerminalAdmissionOptions) {}
get inFlight(): number {
return this.createOrAttachInFlight
}
async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise<unknown> {
const client = this.options.connections.get(clientId)
if (!this.options.isAcceptingWork()) {
throw new Error('Daemon temporarily unavailable; reconnect')
}
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
throw new Error('Daemon client connection is incomplete; reconnect')
}
const payload = request.payload
const attachOnly = payload.attachOnly === true
if (!attachOnly && this.options.endpoint.hasLostOwnership()) {
this.options.requestEndpointRetirement()
throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE)
}
this.createOrAttachInFlight++
let routedSessionId = payload.sessionId
let result: CreateOrAttachResult
let spawnPreparation: PendingPtySpawnPreparation | null = null
try {
if (
payload.agentSessionEnsure !== undefined &&
(!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) ||
!isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface))
) {
throw new Error('agent_session_identity_required')
}
spawnPreparation = this.options.preparations.register(
payload.sessionId,
clientId,
request.id,
payload.cancelAfterMs
)
if (!attachOnly) {
await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation)
}
if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) {
throw new Error('Multiple terminal history seed sources')
}
const historySeedChunks =
payload.historySeedTransferId !== undefined
? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId)
: payload.historySeed !== undefined
? [payload.historySeed]
: undefined
result = await this.options.host.createOrAttach({
sessionId: payload.sessionId,
cols: payload.cols,
rows: payload.rows,
cwd: payload.cwd,
env: payload.env,
envToDelete: payload.envToDelete,
command: payload.command,
startupCommandDelivery: payload.startupCommandDelivery,
...(attachOnly ? { attachOnly: true } : {}),
...(payload.expectedIncarnationId !== undefined
? { expectedIncarnationId: payload.expectedIncarnationId }
: {}),
...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}),
shellOverride: payload.shellOverride,
terminalWindowsWslDistro: payload.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation,
shellReadySupported: payload.shellReadySupported,
historySeedChunks,
startupIngress: parsePtyStartupIngressIntent(payload.startupIngress),
...(payload.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs }
: {}),
...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}),
isCanceled: () => spawnPreparation?.canceled === true,
cancelSignal: spawnPreparation.controller.signal,
onSessionResolved: (sessionId) => {
routedSessionId = sessionId
},
streamClient: this.createStreamClient(clientId, () => routedSessionId)
})
} finally {
if (spawnPreparation) {
this.options.preparations.finish(payload.sessionId, spawnPreparation)
}
this.createOrAttachInFlight--
this.options.reevaluateIdleShutdown()
}
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId
if (
this.options.connections.get(clientId) !== client ||
!client.authenticatedPairEstablished ||
client.streamSocket === null
) {
this.options.host.detach(routedSessionId, result.attachToken)
throw new TerminalAttachCanceledError(routedSessionId)
}
this.options.attachments.attach(routedSessionId, clientId, result.attachToken)
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) {
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId: routedSessionId,
payload: { background: true }
})
}
this.options.log.log(result.isNew ? 'session-created' : 'session-attached', {
sessionId: routedSessionId,
pid: result.pid
})
return {
isNew: result.isNew,
snapshot: result.snapshot,
pid: result.pid,
shellState: result.shellState,
incarnationId: result.incarnationId,
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
}
}
private createStreamClient(
clientId: string,
sessionId: () => string
): CreateOrAttachOptions['streamClient'] {
return {
onData: (data, rawLength = data.length, transformed = false, seq) => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq
})
},
onExit: (code, incarnationId, cause) => {
const routedSessionId = sessionId()
this.options.log.log('session-exited', {
sessionId: routedSessionId,
code,
cause: cause?.kind
})
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'exit',
sessionId: routedSessionId,
payload: { code, incarnationId, ...(cause ? { cause } : {}) }
})
this.options.streamDataBatcher.flush(clientId)
recordDaemonStreamBacklogEvent('sessionExit', {
sessionIdSuffix: routedSessionId.slice(-10)
})
this.options.transientFactRelay.onSessionExit(routedSessionId)
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
this.options.attachments.release(routedSessionId)
this.options.reevaluateIdleShutdown()
}
}
}
}
@@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_MIMOCODE_HOME',
'ORCA_PI_CODING_AGENT_DIR',
'ORCA_OMP_CODING_AGENT_DIR',
'ORCA_CODEX_HOME'
'ORCA_OMP_STATUS_EXTENSION',
'ORCA_CODEX_HOME',
'ORCA_AGENT_TEAMS_SHIM_DIR',
'ORCA_REMOTE_CLI_BIN_DIR'
] as const
export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
import type * as pty from 'node-pty'
import { win32 as pathWin32 } from 'node:path'
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import { isShellProcess } from '../../../shared/shell-process-detection'
import { parsePtySessionId } from '../pty-session-id'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
function resolveFallbackForegroundProcess(
processName: string | null | undefined,
shellPath: string
): string | null {
const normalized = normalizeForegroundProcessName(processName)
if (normalized || process.platform !== 'win32') {
return normalized
}
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
function shouldInspectOuterWrapperFallback(processName: string | null): boolean {
const recognized = recognizeAgentProcess(processName)
return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)
}
export type PtyForegroundProcessTracker = {
recordOutput(data: string): void
markDead(): void
getForegroundProcess(): string | null
confirmForegroundProcess(): Promise<string | null>
}
export function createPtyForegroundProcessTracker(args: {
process: pty.IPty
shellPath: string
cwd?: string
sessionId: string
startupAgentRecognition: RecognizedAgentProcess | null
isDead: () => boolean
}): PtyForegroundProcessTracker {
const proc = args.process
let lastOutputAt = 0
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
const contextPaths = getAgentForegroundContextPaths({
cwd: args.cwd,
worktreeId: parsePtySessionId(args.sessionId).worktreeId
})
let startupAgentForeground: { processName: string; expiresAt: number } | null =
args.startupAgentRecognition
? {
processName: args.startupAgentRecognition.processName,
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
}
: null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackProcess = (): string | null =>
resolveFallbackForegroundProcess(proc.process, args.shellPath)
const getActiveStartupAgent = (
now = Date.now()
): { processName: string; expiresAt: number } | null => {
if (!startupAgentForeground) {
return null
}
if (now > startupAgentForeground.expiresAt) {
startupAgentForeground = null
return null
}
return startupAgentForeground
}
const shouldInspectFallback = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
shouldInspectOuterWrapperFallback(fallbackProcess) ||
process.platform !== 'win32')
const scheduleRefresh = (fallbackProcess: string | null): void => {
if (args.isDead() || !proc.pid) {
return
}
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
!shouldInspectFallback(fallbackProcess)
) {
return
}
const now = Date.now()
const idleNoEvidenceShell =
fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground
const retryMs = !idleNoEvidenceShell
? FOREGROUND_AGENT_CACHE_TTL_MS
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
: SHELL_FOREGROUND_REFRESH_RETRY_MS
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
const retireStaleForegroundIdentity = (): void => {
const currentFallbackProcess = getFallbackProcess()
if (
fallbackIsShell &&
!getActiveStartupAgent() &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
startupAgentForeground = null
} else if (
cachedAgentForeground !== null &&
Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS &&
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
}
}
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
contextPaths
})
.then<string | void>(({ processName, available }) => {
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => {
if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) {
return
}
retireStaleForegroundIdentity()
})
}
retireStaleForegroundIdentity()
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
startupAgentForeground = null
return processName
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
})
.finally(() => {
foregroundRefreshInFlight = false
})
}
return {
recordOutput: (data) => {
if (data.length > 0) {
lastOutputAt = Date.now()
}
},
markDead: () => {
cachedAgentForeground = null
startupAgentForeground = null
},
getForegroundProcess: () => {
if (args.isDead()) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
const inspectOuterWrapper =
fallbackRecognition !== null &&
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
startupAgentForeground = null
return fallbackProcess
}
scheduleRefresh(fallbackProcess)
const now = Date.now()
if (
cachedAgentForeground &&
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
if (
cachedAgentForeground &&
fallbackProcess !== null &&
(isAgentForegroundWrapperProcess(fallbackProcess) ||
inspectOuterWrapper ||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
) {
return cachedAgentForeground.processName
}
const activeStartupAgentForeground = getActiveStartupAgent(now)
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
return activeStartupAgentForeground.processName
}
return fallbackProcess
} catch {
return null
}
},
confirmForegroundProcess: async () => {
if (args.isDead() || !proc.pid) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
process.platform !== 'win32' &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
) {
return fallbackProcess
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid)
}
: {})
}
)
if (args.isDead() || !resolution.available) {
return null
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
cachedAgentForeground = {
processName: recognized.processName,
refreshedAt: Date.now()
}
startupAgentForeground = null
return recognized.processName
}
cachedAgentForeground = null
startupAgentForeground = null
return resolution.processName
} catch {
return null
}
}
}
}
@@ -0,0 +1,82 @@
import * as pty from 'node-pty'
import {
hostReportsChildExitStatus,
wrapShellSpawnForMacosTccAttribution
} from '../../providers/macos-tcc-login-shell'
import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain'
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
export type SpawnedDaemonPty = {
process: pty.IPty
shellPath: string
spawnCwd: string
startupCommandDeliveredInShellArgs?: boolean
/** False when a wrapper owns the reported status, so no exit code may be read from it. */
reportsChildExitStatus: boolean
}
/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
export function spawnNativeDaemonPty(args: {
shellPath: string
shellArgs: string[]
spawnCwd: string
env: Record<string, string>
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void
}): SpawnedDaemonPty {
let reportsChildExitStatus = true
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
// Why: children inherit job membership, so the host job must exist before the first Windows PTY.
if (process.platform === 'win32') {
assignHostProcessToKillOnCloseJob()
}
const proc = pty.spawn(wrapped.file, wrapped.args, {
name: args.env.TERM ?? 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd,
env: args.env,
// Why: bundled ConPTY has the wrap-marker behavior xterm expects.
...(process.platform === 'win32' ? { useConptyDll: true } : {})
})
reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file)
args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped')
return proc
}
try {
const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd)
return {
process: process_,
shellPath: args.shellPath,
spawnCwd: args.spawnCwd,
reportsChildExitStatus
}
} catch (primaryErr) {
if (process.platform !== 'win32') {
throw primaryErr
}
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
try {
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
console.warn(
`[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"`
)
return {
process,
shellPath: attempt.shellPath,
spawnCwd: attempt.effectiveCwd,
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs,
reportsChildExitStatus
}
} catch {
// This fallback shell also failed -- try the next link in the chain.
}
}
throw primaryErr
}
}
@@ -0,0 +1,90 @@
import {
resolveProcessExitCause,
type TerminalExitCause
} from '../../../shared/terminal-exit-cause'
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
type DataListener = (data: string) => void
type ExitListener = (code: number, cause?: TerminalExitCause) => void
/** Preserves spawn-time output and exit ordering until Session installs its listeners. */
export class PtyPreListenerEvents {
private onDataCb: DataListener | null = null
private onExitCb: ExitListener | null = null
private pendingData: string[] = []
private pendingDataChars = 0
private pendingExitCode: number | null = null
private pendingExitCause: TerminalExitCause | null = null
acceptData(data: string): void {
if (this.onDataCb) {
this.onDataCb(data)
return
}
this.pendingData.push(data)
this.pendingDataChars += data.length
while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) {
const removed = this.pendingData.shift()
if (removed === undefined) {
this.pendingDataChars = 0
return
}
this.pendingDataChars -= removed.length
}
}
acceptExit(args: {
exitCode: number
signal?: number
hostReportsChildExitStatus: boolean
}): void {
const cause = resolveProcessExitCause(args)
if (this.onExitCb) {
this.flushData()
this.onExitCb(args.exitCode, cause)
} else {
this.pendingExitCode = args.exitCode
this.pendingExitCause = cause
}
}
onData(cb: DataListener): void {
this.onDataCb = cb
this.flushData()
}
onExit(cb: ExitListener): void {
this.onExitCb = cb
if (this.pendingExitCode === null) {
return
}
const code = this.pendingExitCode
const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code })
this.pendingExitCode = null
this.pendingExitCause = null
this.flushData()
cb(code, cause)
}
clear(): void {
this.onDataCb = null
this.onExitCb = null
this.pendingData = []
this.pendingDataChars = 0
this.pendingExitCode = null
this.pendingExitCause = null
}
private flushData(): void {
if (!this.onDataCb || this.pendingData.length === 0) {
return
}
const pending = this.pendingData
this.pendingData = []
this.pendingDataChars = 0
for (const data of pending) {
this.onDataCb(data)
}
}
}
@@ -0,0 +1,234 @@
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
import { isPwshAvailable } from '../../pwsh'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env'
import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env'
import {
POWERLEVEL10K_WIZARD_DISABLE_ENV,
seedPowerlevel10kWizardEnv
} from '../../pty/powerlevel10k-wizard-env'
import {
resolveSafePtyDefaultCwd,
assertSafeAgentStartupCwd
} from '../../providers/pty-default-cwd'
import {
resolveEffectiveWindowsPowerShell,
shouldProbeWindowsPowerShellAvailability,
type WindowsPowerShellShellFamily
} from '../../providers/windows-powershell'
import {
buildWindowsPowerShellSpawnAttempts,
type WindowsShellSpawnAttempt
} from '../../providers/windows-shell-fallback-chain'
import {
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
resolveWindowsShellLaunchArgs
} from '../../providers/windows-shell-args'
import { resolveUnixShellPath } from '../../providers/local-pty-utils'
import { selectShellStartupFeatures } from '../../shell-startup-features'
import { parseWslPath } from '../../wsl'
import { addWslEnvKeys } from '../../wsl-env'
import {
recognizeAgentProcessFromCommandLine,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
import { resolveWslSessionContext } from '../wsl-session-context'
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
import type { PtySubprocessOptions } from '../pty-subprocess'
export type PtyShellLaunchPlan = {
shellPath: string
shellArgs: string[]
spawnCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
startupAgentRecognition: RecognizedAgentProcess | null
}
export function createPtyShellLaunchPlan(
opts: PtySubprocessOptions,
env: Record<string, string>
): PtyShellLaunchPlan {
const resolvedWslContext = resolveWslSessionContext(opts)
let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
let shellArgs: string[]
let startupCommandDeliveredInShellArgs = false
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd()
if (opts.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(requestedCwd, opts.command)
}
let spawnCwd = requestedCwd
let validationCwd = spawnCwd
if (process.platform === 'win32') {
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath)
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
? normalizedShellFamily
: normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe'
? normalizedShellFamily
: undefined
const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({
shellFamily: resolvedShellFamily,
implementation: opts.terminalWindowsPowerShellImplementation
})
const shouldResolvePowerShellFamily =
opts.terminalWindowsPowerShellImplementation !== undefined ||
pathWin32.basename(shellPath) === shellPath
if (resolvedGitBashPath) {
shellPath = resolvedGitBashPath
} else if (shellPath === WINDOWS_GIT_BASH_SHELL) {
shellPath = 'powershell.exe'
} else {
shellPath = shouldResolvePowerShellFamily
? (resolveEffectiveWindowsPowerShell({
shellFamily: resolvedShellFamily,
implementation: opts.terminalWindowsPowerShellImplementation,
pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false
}) ?? shellPath)
: shellPath
}
if (
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
env.ORCA_CODEX_LAUNCH_PREFLIGHT
) {
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
}
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd: spawnCwd,
defaultCwd: resolveSafePtyDefaultCwd(),
wslContext: resolvedWslContext,
startupCommand: opts.command
})
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
shellPath = primaryAttempt.shellPath
shellArgs = primaryAttempt.shellArgs
spawnCwd = primaryAttempt.effectiveCwd
validationCwd = primaryAttempt.validationCwd
startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs
} else {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
spawnCwd,
resolveSafePtyDefaultCwd(),
resolvedWslContext,
opts.command,
env.ORCA_CODEX_LAUNCH_PREFLIGHT
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
if (isWindowsGitBashShellPath(shellPath)) {
env.CHERE_INVOKING ??= '1'
}
const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
if (codexHomeWslInfo) {
const launchWslDistro = resolvedWslContext?.distro
if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else {
env.CODEX_HOME = codexHomeWslInfo.linuxPath
env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
if (!launchWslDistro) {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
requestedCwd,
resolveSafePtyDefaultCwd(),
{ distro: codexHomeWslInfo.distro },
opts.command,
env.ORCA_CODEX_LAUNCH_PREFLIGHT
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs =
resolved.startupCommandDeliveredInShellArgs === true
}
}
} else if (isHostCodexHomeForWsl(env.CODEX_HOME)) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else if (env.CODEX_HOME) {
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
}
if (env.CLAUDE_CONFIG_DIR) {
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
}
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
addOrcaWslInteropEnv(env)
}
} else {
rescrubDaemonPtyEnvironment(env, opts)
const preferredShellPath = shellPath
shellPath = resolveUnixShellPath(shellPath)
if (shellPath !== preferredShellPath) {
env.SHELL = shellPath
console.warn(
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
)
}
const waitsForShellReady =
Boolean(opts.command) &&
(!isCodexStartupCommand ||
shouldUseShellReadyStartupDelivery({
command: opts.command as string,
startupCommandDelivery: opts.startupCommandDelivery
}))
delete env.ORCA_SHELL_FEATURES
const shellLaunch = getShellLaunchConfig(
shellPath,
selectShellStartupFeatures({
shellPath,
env,
hasStartupCommand: Boolean(opts.command),
waitsForShellReady,
emitsStartupIdentity: waitsForShellReady
})
)
Object.assign(env, shellLaunch.env)
shellArgs = shellLaunch.args ?? ['-l']
}
seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete })
if (
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
process.platform === 'win32' &&
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
) {
addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
finalizeDaemonPtyEnvironment(env, opts.env)
return {
shellPath,
shellArgs,
spawnCwd,
validationCwd,
startupCommandDeliveredInShellArgs,
windowsFallbackAttempts,
startupAgentRecognition
}
}
@@ -0,0 +1,189 @@
import { delimiter } from 'node:path'
import { dropInheritedOrcaFishHistory } from '../../fish-history-session'
import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env'
import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env'
import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir'
import { removeInheritedNoColor } from '../../pty/terminal-color-env'
import { resolvePathEnvKey } from '../../pty/windows-environment-path'
import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path'
import {
gitCredentialPromptGuardEnv,
mergeGitConfigEnvProtocol
} from '../../../shared/git-credential-prompt-env'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard'
import {
expandWindowsEnvironmentVariables,
expandWindowsPathEnvironmentVariables
} from '../../../shared/windows-environment-expansion'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { PtySubprocessOptions } from '../pty-subprocess'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
const WINDOWS_PATH_ENV_KEY_RE = /^path$/i
function composeGuardedDaemonGitConfigEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined,
launchAgent: TuiAgent | undefined
): void {
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
if (policy !== 'guard' && launchAgent === undefined) {
return
}
// Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard.
Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform))
}
function deleteRequestedDaemonEnvKeys(
env: Record<string, string>,
keys: readonly string[] | undefined
): void {
// Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it.
const deleteOrcaOwnedCodexHome =
keys?.includes('ORCA_CODEX_HOME') === true &&
env.ORCA_CODEX_HOME !== undefined &&
env.CODEX_HOME === env.ORCA_CODEX_HOME
for (const key of keys ?? []) {
delete env[key]
}
if (deleteOrcaOwnedCodexHome) {
delete env.CODEX_HOME
}
}
function removeUnspecifiedPaneIdentityEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
for (const key of PANE_IDENTITY_ENV_KEYS) {
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
delete env[key]
}
}
}
/** Removes the second PATH key only when the daemon's env merge created it. */
function collapseWindowsPathEnvKeys(
env: Record<string, string>,
requestedEnv: Record<string, string> | undefined
): void {
if (process.platform !== 'win32') {
return
}
const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
if (pathKeys.length < 2) {
return
}
// Why: a one-key main patch is authoritative; zero or two keys came from inherited state.
const requestedKeys = requestedEnv
? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
: []
if (requestedKeys.length !== 1) {
return
}
const survivingKey = requestedKeys[0]
if (!survivingKey || env[survivingKey] === undefined) {
return
}
for (const key of pathKeys) {
if (key !== survivingKey) {
delete env[key]
}
}
}
/** Promotes the agent-teams shim path ahead of inherited PATH entries. */
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const normalizedRequestedPath =
process.platform === 'win32'
? expandWindowsEnvironmentVariables(requestedPath, env)
: requestedPath
const pathDelimiter = process.platform === 'win32' ? ';' : delimiter
const shimDir = normalizedRequestedPath.split(pathDelimiter)[0]
if (!shimDir) {
return
}
const pathKey = resolvePathEnvKey(env, process.platform)
const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? []
env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter)
}
function removeInheritedDevAgentHookEndpoint(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) {
// Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec.
delete env.ORCA_AGENT_HOOK_ENDPOINT
}
}
export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record<string, string> {
const env: Record<string, string> = {
...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
FORCE_HYPERLINK: '1'
} as Record<string, string>
stripLegacyTerminalShimEnv(env, process.platform)
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
removeUnspecifiedPaneIdentityEnv(env, opts.env)
if (opts.env?.fish_history === undefined) {
dropInheritedOrcaFishHistory(env)
}
if (opts.env?.HISTFILE === undefined) {
dropInheritedOrcaHistFile(env)
}
if (opts.env?.ORCA_HISTFILE === undefined) {
delete env.ORCA_HISTFILE
}
removeInheritedDevAgentHookEndpoint(env, opts.env)
delete env.ELECTRON_RUN_AS_NODE
removeAppImageRuntimeEnv(env)
removeInheritedNoColor(env)
env.LANG ??= 'en_US.UTF-8'
return env
}
export function rescrubDaemonPtyEnvironment(
env: Record<string, string>,
opts: PtySubprocessOptions
): void {
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
}
export function finalizeDaemonPtyEnvironment(
env: Record<string, string>,
requestedEnv: Record<string, string> | undefined
): void {
expandWindowsPathEnvironmentVariables(env)
collapseWindowsPathEnvKeys(env, requestedEnv)
const requestedPath = requestedEnv
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
: undefined
promoteAgentTeamsShimPath(env, requestedPath)
stripLegacyTerminalShimEnv(env, process.platform)
dropIncoherentCondaActivationEnv(env, process.platform)
}
@@ -0,0 +1,221 @@
import * as pty from 'node-pty'
import { statSync } from 'node:fs'
import { release } from 'node:os'
import {
ensureNodePtySpawnHelperExecutable,
getNodePtySpawnHelperCandidates,
validateWorkingDirectoryAsync,
WorkingDirectoryValidationAbortedError
} from '../../providers/local-pty-utils'
import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd'
import { TerminalAttachCanceledError } from '../daemon-errors'
import { DaemonProtocolError } from '../types'
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
function daemonEnvironmentDiagSuffix(): string {
const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev'
const systemVersion =
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ||
release()
return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})`
}
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
const detailName = kind === 'helper' ? 'helper' : 'cwd'
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory'
return new DaemonProtocolError(
`Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}`
)
}
function isExistingDirectory(path: string | undefined): path is string {
if (!path) {
return false
}
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
function repairDaemonCwd(): string | null {
const candidates = [process.env.ORCA_USER_DATA_PATH]
try {
candidates.push(resolveSafePtyDefaultCwd())
} catch {
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
}
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
for (const candidate of candidates) {
if (isExistingDirectory(candidate)) {
try {
process.chdir(candidate)
return candidate
} catch {
// Try the next stable cwd candidate.
}
}
}
return null
}
function preflightDaemonCwd(): void {
let daemonCwd = '<unavailable>'
try {
daemonCwd = process.cwd()
if (isExistingDirectory(daemonCwd)) {
return
}
} catch {
// Recover below; process.cwd() throws after the original cwd is deleted.
}
if (repairDaemonCwd()) {
return
}
throw formatMissingDaemonPathError('cwd', daemonCwd)
}
function preflightMacNodePtySpawnEnvironment(): void {
if (process.platform !== 'darwin') {
return
}
let candidates: string[]
try {
candidates = getNodePtySpawnHelperCandidates()
} catch {
throw formatMissingDaemonPathError('helper', '<unresolved>')
}
for (const candidate of candidates) {
try {
if (statSync(candidate).isFile()) {
return
}
} catch {
// Try the next node-pty native location.
}
}
throw formatMissingDaemonPathError('helper', candidates[0] ?? '<unresolved>')
}
function preflightUnixPtySpawnEnvironment(): void {
if (process.platform === 'win32') {
return
}
// Why: detached daemons can outlive their launch cwd; repair before every spawn.
preflightDaemonCwd()
preflightMacNodePtySpawnEnvironment()
}
function isNativeWindowsPath(path: string): boolean {
return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
}
export async function preflightPtySpawn(args: {
validationCwd: string
cwdWasExplicit: boolean
sessionId: string
signal?: AbortSignal
}): Promise<void> {
ensureNodePtySpawnHelperExecutable()
preflightUnixPtySpawnEnvironment()
try {
if (process.platform === 'win32') {
if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) {
await validateWorkingDirectoryAsync(
args.validationCwd,
args.signal ? { signal: args.signal } : {}
)
}
} else {
await validateWorkingDirectoryAsync(
args.validationCwd,
args.signal ? { signal: args.signal } : {}
)
}
} catch (error) {
if (error instanceof WorkingDirectoryValidationAbortedError) {
throw new TerminalAttachCanceledError(args.sessionId)
}
throw error
}
}
export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error {
const message = err instanceof Error ? err.message : String(err)
const formatted = new DaemonProtocolError(
`Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}`
)
if (err instanceof Error && err.stack) {
formatted.stack = err.stack
}
return formatted
}
export function runPtySpawnHealthProbe(): Promise<void> {
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: resolveSafePtyDefaultCwd()
let proc: pty.IPty
try {
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env: { ...process.env, TERM: 'xterm-256color' }
})
} catch (err) {
throw formatPtySpawnError(err, '/bin/sh', cwd)
}
return new Promise<void>((resolve, reject) => {
let settled = false
let exitDisposable: { dispose(): void } | undefined
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
exitDisposable?.dispose()
if (opts?.kill) {
try {
proc.kill()
} catch {
// Best-effort cleanup for a short-lived health probe.
}
}
if (error) {
reject(error)
} else {
resolve()
}
}
const timer = setTimeout(() => {
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
kill: true
})
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
} else {
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
}
})
})
}
export function preflightPtySpawnHealth(): boolean {
if (process.platform === 'win32') {
return false
}
if (process.platform === 'darwin') {
ensureNodePtySpawnHelperExecutable()
}
preflightUnixPtySpawnEnvironment()
return true
}
@@ -0,0 +1,202 @@
import type * as pty from 'node-pty'
import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition'
import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo'
import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups'
import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group'
import { readPtsName } from '../../pty/node-pty-pts-name'
import { terminatePtyJob } from '../../windows/windows-pty-job'
import { isValidPtySize } from '../daemon-pty-size'
import type { SubprocessHandle } from '../session-subprocess-handle'
import { createPtyForegroundProcessTracker } from './foreground-process-tracker'
import { PtyPreListenerEvents } from './pre-listener-events'
type DisposableNativePty = pty.IPty & { destroy?: () => void }
export function createDaemonPtySubprocessHandle(args: {
process: pty.IPty
shellPath: string
spawnCwd: string
env: Record<string, string>
startupCommandDeliveredInShellArgs: boolean
reportsChildExitStatus: boolean
requestedCwd?: string
sessionId: string
startupAgentRecognition: RecognizedAgentProcess | null
}): SubprocessHandle {
const proc = args.process
// node-pty exposes destroy at runtime but omits it from IPty.
const nativeProc = proc as DisposableNativePty
const events = new PtyPreListenerEvents()
let dead = false
let disposed = false
let nodePtyKillIssued = false
const foreground = createPtyForegroundProcessTracker({
process: proc,
shellPath: args.shellPath,
cwd: args.requestedCwd,
sessionId: args.sessionId,
startupAgentRecognition: args.startupAgentRecognition,
isDead: () => dead
})
proc.onData((data) => {
foreground.recordOutput(data)
events.acceptData(data)
})
proc.onExit(({ exitCode, signal }) => {
events.acceptExit({
exitCode,
signal,
hostReportsChildExitStatus: args.reportsChildExitStatus
})
})
proc.onExit(() => {
dead = true
foreground.markDead()
// Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid.
if (process.platform !== 'win32') {
nativeProc.kill = () => {}
}
})
const slavePath = readPtySlavePath(proc)
return {
pid: proc.pid,
shellPath: args.shellPath,
shellCwd: args.spawnCwd,
shellPathEnv: args.env.PATH,
...(slavePath ? { slavePath } : {}),
...(args.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
getForegroundProcess: foreground.getForegroundProcess,
confirmForegroundProcess: foreground.confirmForegroundProcess,
write: (data) => {
if (dead) {
return
}
try {
proc.write(data)
} catch {
dead = true
}
},
resize: (cols, rows) => {
if (dead || !isValidPtySize(cols, rows)) {
return
}
try {
proc.resize(cols, rows)
} catch {
dead = true
}
},
// WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child.
pause: () => {
if (dead) {
return
}
try {
proc.pause()
} catch {
// Native handle already torn down; flow control is best-effort.
}
},
resume: () => {
if (dead) {
return
}
try {
proc.resume()
} catch {
// Native handle already torn down; flow control is best-effort.
}
},
clear: () => {
if (dead) {
return
}
try {
proc.clear()
} catch {
// A clear on a just-exited PTY is best-effort.
}
},
kill: () => {
if (dead) {
return
}
nodePtyKillIssued = true
try {
proc.kill()
} catch (error) {
// A rejected native kill is not proof of exit; keep the wrapper live for a retry.
nodePtyKillIssued = false
throw error
}
},
terminateOwnedTree: () => terminatePtyJob(proc),
forceKill: () => {
if (dead) {
return
}
// Escalate a ConPTY kill through the job without double-closing node-pty's shell handle.
if (process.platform === 'win32' && nodePtyKillIssued) {
terminatePtyJob(proc)
return
}
try {
forceKillPosixPtyProcessGroups(proc.pid, () => {
process.kill(proc.pid, 'SIGKILL')
})
} catch (signalError) {
try {
proc.kill()
nodePtyKillIssued = true
} catch {
nodePtyKillIssued = false
throw signalError
}
}
},
signal: (sig) => {
if (dead) {
return
}
const signalRootPid = (): void => {
try {
process.kill(proc.pid, sig)
} catch {
// Process may already be dead.
}
}
// SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target.
if (sig === 'SIGWINCH') {
signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid)
return
}
signalRootPid()
},
onData: (cb) => events.onData(cb),
onExit: (cb) => events.onExit(cb),
dispose: () => {
if (disposed) {
return
}
disposed = true
dead = true
events.clear()
// POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY.
if (process.platform !== 'win32') {
nativeProc.kill = () => {}
} else if (nodePtyKillIssued) {
return
}
try {
nativeProc.destroy?.()
} catch {
// Native handle was already torn down.
}
}
}
}
+12 -8
View File
@@ -60,20 +60,24 @@ import { githubProjectHost } from '../../shared/github/project-identity'
// Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only.
export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals'
export { classifyProjectError } from './project-view/project-error-classification'
export { updateIssueBySlug } from './project-view/mutations'
export {
updateProjectItemFieldValue,
clearProjectItemFieldValue,
updateIssueBySlug,
updatePullRequestBySlug,
clearProjectItemFieldValue
} from './project-view/project-field-mutations'
export { updatePullRequestBySlug } from './project-view/pull-request-mutation'
export {
addIssueCommentBySlug,
updateIssueCommentBySlug,
deleteIssueCommentBySlug,
deleteIssueCommentBySlug
} from './project-view/issue-comment-mutations'
export {
listLabelsBySlug,
listAssignableUsersBySlug,
listIssueTypesBySlug,
updateIssueTypeBySlug,
getWorkItemDetailsBySlug
} from './project-view/mutations'
listIssueTypesBySlug
} from './project-view/repository-field-options'
export { updateIssueTypeBySlug } from './project-view/issue-type-mutation'
export { getWorkItemDetailsBySlug } from './project-view/work-item-details'
// ─── Constants ─────────────────────────────────────────────────────────
@@ -0,0 +1,111 @@
import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals'
import type { PRComment } from '../../../shared/github/comment-types'
import type {
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult
} from '../../../shared/github/project-result-types'
import type {
AddIssueCommentBySlugArgs,
DeleteIssueCommentBySlugArgs,
UpdateIssueCommentBySlugArgs
} from '../../../shared/github/project-request-types'
type RawIssueCommentResponse = {
id?: number
user?: { login?: string; avatar_url?: string; type?: string } | null
body?: string
created_at?: string
html_url?: string
}
function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment {
return {
id: data.id ?? Date.now(),
author: data.user?.login ?? 'You',
authorAvatarUrl: data.user?.avatar_url ?? '',
body: data.body ?? fallbackBody,
createdAt: data.created_at ?? new Date().toISOString(),
url: data.html_url ?? '',
isBot: data.user?.type === 'Bot'
}
}
export async function addIssueCommentBySlug(
args: AddIssueCommentBySlugArgs
): Promise<GitHubProjectCommentMutationResult> {
const slug = validateSlugArgs(args.owner, args.repo)
if (!slug.ok) {
return slug
}
const number = assertPositiveInt(args.number, 'number')
if (!number.ok) {
return { ok: false, error: number.error }
}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
}
const result = await runRest<RawIssueCommentResponse>(
[
'-X',
'POST',
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
'--raw-field',
`body=${args.body}`
],
undefined,
'core',
projectGhExecOptions(args.host)
)
return result.ok
? { ok: true, comment: mapIssueComment(result.data, args.body) }
: { ok: false, error: result.error }
}
export async function updateIssueCommentBySlug(
args: UpdateIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> {
const slug = validateSlugArgs(args.owner, args.repo)
if (!slug.ok) {
return slug
}
const number = assertPositiveInt(args.commentId, 'commentId')
if (!number.ok) {
return { ok: false, error: number.error }
}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
}
const result = await runRest<unknown>(
[
'-X',
'PATCH',
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
'--raw-field',
`body=${args.body}`
],
undefined,
'core',
projectGhExecOptions(args.host)
)
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
export async function deleteIssueCommentBySlug(
args: DeleteIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> {
const slug = validateSlugArgs(args.owner, args.repo)
if (!slug.ok) {
return slug
}
const number = assertPositiveInt(args.commentId, 'commentId')
if (!number.ok) {
return { ok: false, error: number.error }
}
const result = await runRest<unknown>(
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
undefined,
'core',
{ expectEmpty: true, ...projectGhExecOptions(args.host) }
)
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
@@ -0,0 +1,54 @@
import {
assertPositiveInt,
projectGhExecOptions,
runGraphql,
validateSlugArgs,
type GraphqlVars
} from './internals'
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
import type { UpdateIssueTypeBySlugArgs } from '../../../shared/github/project-request-types'
export async function updateIssueTypeBySlug(
args: UpdateIssueTypeBySlugArgs
): Promise<GitHubProjectMutationResult> {
const validation = validateSlugArgs(args.owner, args.repo)
if (!validation.ok) {
return validation
}
const number = assertPositiveInt(args.number, 'number')
if (!number.ok) {
return { ok: false, error: number.error }
}
const lookup = await runGraphql<{
repository?: { issue?: { id?: string } | null } | null
}>(
`query($owner:String!, $repo:String!, $num:Int!) {
repository(owner:$owner, name:$repo) { issue(number:$num) { id } }
}`,
{ owner: args.owner, repo: args.repo, num: args.number },
projectGhExecOptions(args.host)
)
if (!lookup.ok) {
return { ok: false, error: lookup.error }
}
const issueId = lookup.data.repository?.issue?.id
if (!issueId) {
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
}
const query = args.issueTypeId
? `mutation($issueId:ID!, $issueTypeId:ID!) {
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) {
issue { id }
}
}`
: `mutation($issueId:ID!) {
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) {
issue { id }
}
}`
const vars: GraphqlVars = args.issueTypeId
? { issueId, issueTypeId: args.issueTypeId }
: { issueId }
const result = await runGraphql<unknown>(query, vars, projectGhExecOptions(args.host))
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
@@ -33,7 +33,7 @@ vi.mock('./internals', () => ({
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
projectHostAuthenticationError: projectHostAuthenticationErrorMock,
projectGhExecOptions: (host?: string) => (host ? { host } : {}),
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }),
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
runGraphql: runGraphqlMock,
@@ -46,7 +46,8 @@ vi.mock('./internals', () => ({
: { ok: false, error: { type: 'validation_error', message: `${name} invalid` } }
}))
import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations'
import { updateIssueBySlug } from './mutations'
import { getWorkItemDetailsBySlug } from './work-item-details'
describe('updateIssueBySlug', () => {
beforeEach(() => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { runGraphqlMock } = vi.hoisted(() => ({ runGraphqlMock: vi.fn() }))
vi.mock('./internals', () => ({
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
runGraphql: runGraphqlMock
}))
import { clearProjectItemFieldValue, updateProjectItemFieldValue } from './project-field-mutations'
describe('project field mutations', () => {
beforeEach(() => {
runGraphqlMock.mockReset().mockResolvedValue({ ok: true, data: {} })
})
it('maps each field value kind to a host-pinned GraphQL variable', async () => {
await updateProjectItemFieldValue({
projectId: 'PVT_1',
itemId: 'PVTI_1',
fieldId: 'PVTF_1',
value: { kind: 'number', number: 12.5 },
host: 'github.corp.example'
})
expect(runGraphqlMock).toHaveBeenCalledWith(
expect.stringContaining('$value:Float!'),
{ projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1', value: 12.5 },
{ host: 'github.corp.example' }
)
})
it('rejects malformed field kinds without dispatching GraphQL', async () => {
const result = await updateProjectItemFieldValue({
projectId: 'PVT_1',
itemId: 'PVTI_1',
fieldId: 'PVTF_1',
value: { kind: 'future-kind' } as never
})
expect(result).toMatchObject({ ok: false, error: { type: 'validation_error' } })
expect(runGraphqlMock).not.toHaveBeenCalled()
})
it('keeps clear mutations host-scoped', async () => {
await clearProjectItemFieldValue({
projectId: 'PVT_1',
itemId: 'PVTI_1',
fieldId: 'PVTF_1',
host: 'github.corp.example'
})
expect(runGraphqlMock).toHaveBeenCalledWith(
expect.stringContaining('clearProjectV2ItemFieldValue'),
{ projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1' },
{ host: 'github.corp.example' }
)
})
})
@@ -0,0 +1,103 @@
import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals'
import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types'
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
import type {
ClearProjectItemFieldArgs,
UpdateProjectItemFieldArgs
} from '../../../shared/github/project-request-types'
class UnknownFieldMutationKindError extends Error {
constructor(kind: string) {
super(`Unknown project field mutation kind: ${kind}`)
}
}
function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string {
switch (value.kind) {
case 'single-select':
return 'singleSelectOptionId: $value'
case 'iteration':
return 'iterationId: $value'
case 'text':
return 'text: $value'
case 'number':
return 'number: $value'
case 'date':
return 'date: $value'
}
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
function mutationValueVar(value: GitHubProjectFieldMutationValue): {
type: string
val: string | number
} {
switch (value.kind) {
case 'single-select':
return { type: 'String!', val: value.optionId }
case 'iteration':
return { type: 'String!', val: value.iterationId }
case 'text':
return { type: 'String!', val: value.text }
case 'number':
return { type: 'Float!', val: value.number }
case 'date':
return { type: 'Date!', val: value.date }
}
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
export async function updateProjectItemFieldValue(
args: UpdateProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
}
let valueFragment: string
let valueVariable: { type: string; val: string | number }
try {
valueFragment = graphqlValueForFieldMutation(args.value)
valueVariable = mutationValueVar(args.value)
} catch (error) {
if (error instanceof UnknownFieldMutationKindError) {
return { ok: false, error: { type: 'validation_error', message: error.message } }
}
throw error
}
const query = `
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valueVariable.type}) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { ${valueFragment} }
}) { projectV2Item { id } }
}
`
const vars: GraphqlVars = {
projectId: args.projectId,
itemId: args.itemId,
fieldId: args.fieldId,
value: valueVariable.val
}
const result = await runGraphql<unknown>(query, vars, projectGhExecOptions(args.host))
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
export async function clearProjectItemFieldValue(
args: ClearProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
}
const result = await runGraphql<unknown>(
`mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) {
clearProjectV2ItemFieldValue(input: {
projectId: $projectId itemId: $itemId fieldId: $fieldId
}) { projectV2Item { id } }
}`,
{ projectId: args.projectId, itemId: args.itemId, fieldId: args.fieldId },
projectGhExecOptions(args.host)
)
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
@@ -0,0 +1,47 @@
import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals'
import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types'
import type { UpdatePullRequestBySlugArgs } from '../../../shared/github/project-request-types'
export async function updatePullRequestBySlug(
args: UpdatePullRequestBySlugArgs
): Promise<GitHubProjectMutationResult> {
const slug = validateSlugArgs(args.owner, args.repo)
if (!slug.ok) {
return slug
}
const number = assertPositiveInt(args.number, 'number')
if (!number.ok) {
return { ok: false, error: number.error }
}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
}
const patchArgs: string[] = [
'-X',
'PATCH',
`repos/${args.owner}/${args.repo}/pulls/${args.number}`
]
let fieldCount = 0
if (args.updates.title !== undefined) {
patchArgs.push('--raw-field', `title=${args.updates.title}`)
fieldCount++
}
if (args.updates.body !== undefined) {
patchArgs.push('--raw-field', `body=${args.updates.body}`)
fieldCount++
}
if (args.updates.state !== undefined) {
patchArgs.push('--raw-field', `state=${args.updates.state}`)
fieldCount++
}
if (fieldCount === 0) {
return { ok: true }
}
const result = await runRest<unknown>(
patchArgs,
undefined,
'core',
projectGhExecOptions(args.host)
)
return result.ok ? { ok: true } : { ok: false, error: result.error }
}
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { acquireMock, ghExecFileAsyncMock, noteRepositoryRateLimitSpendMock, releaseMock } =
vi.hoisted(() => ({
acquireMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
noteRepositoryRateLimitSpendMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./internals', () => ({
acquire: acquireMock,
release: releaseMock,
extractExecError: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }),
projectHostAuthenticationError: vi.fn().mockResolvedValue(null),
repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false }),
runGraphql: vi.fn(),
validateSlugArgs: () => ({ ok: true })
}))
vi.mock('./project-error-classification', () => ({
classifyProjectError: vi.fn(),
rateLimitedError: vi.fn()
}))
import { listAssignableUsersBySlug, listLabelsBySlug } from './repository-field-options'
describe('repository field options', () => {
beforeEach(() => {
acquireMock.mockReset().mockResolvedValue(undefined)
releaseMock.mockReset()
noteRepositoryRateLimitSpendMock.mockReset()
ghExecFileAsyncMock.mockReset()
})
it('keeps label discovery paginated and host-scoped', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: 'bug\nenhancement\n', stderr: '' })
await expect(
listLabelsBySlug({ owner: 'acme', repo: 'widgets', host: 'github.corp.example' })
).resolves.toEqual({ ok: true, labels: ['bug', 'enhancement'] })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--paginate', 'repos/acme/widgets/labels', '--jq', '.[].name'],
{ encoding: 'utf-8', host: 'github.corp.example' }
)
expect(releaseMock).toHaveBeenCalledOnce()
})
it('merges seed assignees after paginated provider results', async () => {
ghExecFileAsyncMock.mockResolvedValue({
stdout: '{"login":"octo","name":null,"avatarUrl":"octo.png"}\n',
stderr: ''
})
const result = await listAssignableUsersBySlug({
owner: 'acme',
repo: 'widgets',
seedLogins: ['octo', 'hubot']
})
expect(result).toEqual({
ok: true,
users: [
{ login: 'octo', name: null, avatarUrl: 'octo.png' },
{ login: 'hubot', name: null, avatarUrl: '' }
]
})
})
})
@@ -0,0 +1,165 @@
import {
acquire,
extractExecError,
ghExecFileAsync,
noteRepositoryRateLimitSpend,
projectGhExecOptions,
projectHostAuthenticationError,
release,
repositoryRateLimitGuard,
runGraphql,
validateSlugArgs
} from './internals'
import { classifyProjectError, rateLimitedError } from './project-error-classification'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type {
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugResult,
ListLabelsBySlugResult
} from '../../../shared/github/project-result-types'
import type {
ListAssignableUsersBySlugArgs,
ListIssueTypesBySlugArgs,
ListLabelsBySlugArgs
} from '../../../shared/github/project-request-types'
export async function listLabelsBySlug(
args: ListLabelsBySlugArgs
): Promise<ListLabelsBySlugResult> {
const validation = validateSlugArgs(args.owner, args.repo)
if (!validation.ok) {
return validation
}
const authError = await projectHostAuthenticationError(args.host)
if (authError) {
return { ok: false, error: authError }
}
const guard = repositoryRateLimitGuard(args, 'core')
if (guard.blocked) {
return { ok: false, error: rateLimitedError(guard) }
}
await acquire()
noteRepositoryRateLimitSpend(args, 'core')
try {
const { stdout } = await ghExecFileAsync(
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
{ encoding: 'utf-8', ...projectGhExecOptions(args.host) }
)
return { ok: true, labels: stdout.trim().split('\n').filter(Boolean) }
} catch (error) {
const { stderr, stdout } = extractExecError(error)
return { ok: false, error: classifyProjectError(stderr, stdout, args.host) }
} finally {
release()
}
}
export async function listAssignableUsersBySlug(
args: ListAssignableUsersBySlugArgs
): Promise<ListAssignableUsersBySlugResult> {
const validation = validateSlugArgs(args.owner, args.repo)
if (!validation.ok) {
return validation
}
const authError = await projectHostAuthenticationError(args.host)
if (authError) {
return { ok: false, error: authError }
}
const guard = repositoryRateLimitGuard(args, 'core')
if (guard.blocked) {
return { ok: false, error: rateLimitedError(guard) }
}
const users: GitHubAssignableUser[] = []
await acquire()
noteRepositoryRateLimitSpend(args, 'core')
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${args.owner}/${args.repo}/assignees`,
'--jq',
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
],
{ encoding: 'utf-8', ...projectGhExecOptions(args.host) }
)
for (const line of stdout.trim().split('\n').filter(Boolean)) {
try {
const user = JSON.parse(line) as {
login?: string
avatarUrl?: string
name?: string | null
}
if (typeof user.login === 'string') {
users.push({
login: user.login,
name: user.name ?? null,
avatarUrl: user.avatarUrl ?? ''
})
}
} catch {
// Skip malformed jq output.
}
}
} catch (error) {
const { stderr } = extractExecError(error)
return { ok: false, error: classifyProjectError(stderr, '', args.host) }
} finally {
release()
}
const seen = new Set(users.map((user) => user.login))
for (const login of args.seedLogins ?? []) {
if (typeof login === 'string' && !seen.has(login)) {
users.push({ login, name: null, avatarUrl: '' })
seen.add(login)
}
}
return { ok: true, users }
}
export async function listIssueTypesBySlug(
args: ListIssueTypesBySlugArgs
): Promise<ListIssueTypesBySlugResult> {
const validation = validateSlugArgs(args.owner, args.repo)
if (!validation.ok) {
return validation
}
const result = await runGraphql<{
repository?: {
issueTypes?: {
nodes?: ({
id?: string
name?: string
color?: string | null
description?: string | null
} | null)[]
} | null
} | null
}>(
`query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
issueTypes(first:50) { nodes { id name color description } }
}
}`,
{ owner: args.owner, repo: args.repo },
projectGhExecOptions(args.host)
)
if (!result.ok) {
if (result.error.type === 'schema_drift' || result.error.type === 'validation_error') {
return { ok: true, types: [] }
}
return { ok: false, error: result.error }
}
const types = (result.data.repository?.issueTypes?.nodes ?? [])
.filter(
(node): node is NonNullable<typeof node> =>
node !== null && typeof node.id === 'string' && typeof node.name === 'string'
)
.map((node) => ({
id: node.id as string,
name: node.name as string,
color: typeof node.color === 'string' ? node.color : null,
description: typeof node.description === 'string' ? node.description : null
}))
return { ok: true, types }
}
@@ -0,0 +1,162 @@
import { assertPositiveInt, projectGhExecOptions, runGraphql, validateSlugArgs } from './internals'
import type { PRComment } from '../../../shared/github/comment-types'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types'
import type { ProjectWorkItemDetailsBySlugResult } from '../../../shared/github/project-result-types'
import type { ProjectWorkItemDetailsBySlugArgs } from '../../../shared/github/project-request-types'
type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null }
type RawLabel = { name?: string; color?: string }
type RawComment = {
databaseId?: number
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
body?: string
createdAt?: string
url?: string
}
type RawWorkItemContent = {
id?: string
number?: number
title?: string
url?: string
state?: string
stateReason?: string | null
isDraft?: boolean
updatedAt?: string
body?: string
headRefName?: string
baseRefName?: string
author?: { login?: string } | null
labels?: { nodes?: RawLabel[] }
assignees?: { nodes?: RawUser[] }
participants?: { nodes?: RawUser[] }
comments?: { nodes?: (RawComment | null)[] }
}
export async function getWorkItemDetailsBySlug(
args: ProjectWorkItemDetailsBySlugArgs
): Promise<ProjectWorkItemDetailsBySlugResult> {
const validation = validateSlugArgs(args.owner, args.repo)
if (!validation.ok) {
return validation
}
const number = assertPositiveInt(args.number, 'number')
if (!number.ok) {
return { ok: false, error: number.error }
}
if (args.type !== 'issue' && args.type !== 'pr') {
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
}
const contentFragment = args.type === 'issue' ? ISSUE_FRAGMENT : PULL_REQUEST_FRAGMENT
const result = await runGraphql<{
repository?: {
issue?: RawWorkItemContent | null
pullRequest?: RawWorkItemContent | null
} | null
}>(
`query($owner:String!, $repo:String!, $num:Int!) {
repository(owner:$owner, name:$repo) { ${contentFragment} }
}`,
{ owner: args.owner, repo: args.repo, num: args.number },
projectGhExecOptions(args.host)
)
if (!result.ok) {
return { ok: false, error: result.error }
}
const raw =
args.type === 'issue' ? result.data.repository?.issue : result.data.repository?.pullRequest
if (!raw) {
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
}
return { ok: true, details: mapWorkItemDetails(raw, args) }
}
const ISSUE_FRAGMENT = `issue(number:$num) {
id number title url state stateReason updatedAt body author { login }
labels(first:50) { nodes { name } }
assignees(first:50) { nodes { login } }
participants(first:50) { nodes { login name avatarUrl } }
comments(first:100) {
nodes { databaseId author { login avatarUrl __typename } body createdAt url }
}
}`
const PULL_REQUEST_FRAGMENT = `pullRequest(number:$num) {
id number title url state isDraft updatedAt headRefName baseRefName body author { login }
labels(first:50) { nodes { name } }
assignees(first:50) { nodes { login } }
participants(first:50) { nodes { login name avatarUrl } }
comments(first:100) {
nodes { databaseId author { login avatarUrl __typename } body createdAt url }
}
}`
function mapWorkItemDetails(
raw: RawWorkItemContent,
args: ProjectWorkItemDetailsBySlugArgs
): GitHubWorkItemDetails {
const labels = (raw.labels?.nodes ?? [])
.map((label) => label?.name)
.filter((name): name is string => typeof name === 'string')
const assignees = (raw.assignees?.nodes ?? [])
.map((assignee) => assignee?.login)
.filter((login): login is string => typeof login === 'string')
const comments: PRComment[] = []
for (const comment of raw.comments?.nodes ?? []) {
if (!comment || typeof comment.body !== 'string') {
continue
}
comments.push({
id: typeof comment.databaseId === 'number' ? comment.databaseId : Date.now(),
author: comment.author?.login ?? '',
authorAvatarUrl: comment.author?.avatarUrl ?? '',
body: comment.body,
createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : '',
url: typeof comment.url === 'string' ? comment.url : '',
isBot: comment.author?.__typename === 'Bot'
})
}
const participants: GitHubAssignableUser[] = []
for (const participant of raw.participants?.nodes ?? []) {
if (participant && typeof participant.login === 'string') {
participants.push({
login: participant.login,
name: participant.name ?? null,
avatarUrl: participant.avatarUrl ?? ''
})
}
}
const state: 'open' | 'closed' | 'merged' | 'draft' =
args.type === 'pr'
? raw.isDraft
? 'draft'
: raw.state === 'MERGED'
? 'merged'
: raw.state === 'CLOSED'
? 'closed'
: 'open'
: raw.state === 'CLOSED'
? 'closed'
: 'open'
return {
item: {
id: typeof raw.id === 'string' ? raw.id : '',
type: args.type,
number: typeof raw.number === 'number' ? raw.number : args.number,
title: typeof raw.title === 'string' ? raw.title : '',
state,
url: typeof raw.url === 'string' ? raw.url : '',
labels,
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '',
author: typeof raw.author?.login === 'string' ? raw.author.login : null,
branchName:
args.type === 'pr' && typeof raw.headRefName === 'string' ? raw.headRefName : undefined,
baseRefName:
args.type === 'pr' && typeof raw.baseRefName === 'string' ? raw.baseRefName : undefined
},
body: typeof raw.body === 'string' ? raw.body : '',
comments,
participants,
assignees
}
}
+2 -1
View File
@@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => {
_resetRendererErrorReportDedupeForTests()
})
it('copies the latest pending diagnostic text to the clipboard', async () => {
it('copies the requested captured report to the clipboard', async () => {
const latest = report()
registerCrashReportingHandlers({
getById: vi.fn(async () => latest),
@@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => {
} as never)
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
reportId: latest.id,
notes: 'extra /Users/alice/project'
})
@@ -0,0 +1,67 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { isFolderRepo } from '../../../shared/repo-kind'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks'
import { isENOENT } from '../filesystem-path-containment'
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void {
const { store } = context
ipcMain.handle(
'hooks:check',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo) {
const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId)
// Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free.
return {
status: args.hostId || repoIdExists ? 'error' : 'ok',
hasHooks: false,
hooks: null,
mayNeedUpdate: false
}
}
if (isFolderRepo(repo)) {
return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }
}
if (repo.connectionId) {
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false }
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
return {
status: 'ok',
hasHooks: !result.isBinary,
hooks: result.isBinary ? null : parseOrcaYaml(result.content),
mayNeedUpdate: false
}
} catch (error) {
return {
status: isENOENT(error) ? 'ok' : 'error',
hasHooks: false,
hooks: null,
mayNeedUpdate: false
}
}
}
const has = hasHooksFile(repo.path)
const hooks = has ? loadHooks(repo.path) : null
// Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed".
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
return {
status: 'ok',
hasHooks: has,
hooks,
mayNeedUpdate
}
}
)
}
@@ -0,0 +1,126 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { isFolderRepo } from '../../../shared/repo-kind'
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { isENOENT } from '../filesystem-path-containment'
import { parseOrcaYaml } from '../../hooks'
import { readIssueCommand, writeIssueCommand } from '../../issue-command-file'
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void {
const { store } = context
ipcMain.handle(
'hooks:readIssueCommand',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return {
status: 'ok',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none' as const
}
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: issueCommandPath,
source: 'none' as const
}
}
let status: 'ok' | 'error' = 'ok'
let localContent: string | null = null
let sharedContent: string | null = null
try {
const result = await fsProvider.readFile(issueCommandPath)
localContent = result.isBinary ? null : result.content.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
sharedContent = result.isBinary
? null
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
const effectiveContent = localContent ?? sharedContent
return {
status: localContent ? 'ok' : status,
localContent,
sharedContent,
effectiveContent,
localFilePath: issueCommandPath,
source: localContent
? ('local' as const)
: sharedContent
? ('shared' as const)
: ('none' as const)
}
}
return readIssueCommand(repo.path)
}
)
ipcMain.handle(
'hooks:writeIssueCommand',
async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => {
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
throw new Error(
'Remote filesystem unavailable. Reconnect the SSH target before retrying.'
)
}
const trimmed = args.content.trim()
if (!trimmed) {
await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => {
if (!isENOENT(error)) {
throw error
}
})
return
}
await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca'))
const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore')
try {
const result = await fsProvider.readFile(gitignorePath)
if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) {
const separator = result.content.endsWith('\n') ? '' : '\n'
await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`)
}
} catch (error) {
if (!isENOENT(error)) {
throw error
}
await fsProvider.writeFile(gitignorePath, '.orca\n')
}
await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`)
return
}
writeIssueCommand(repo.path, args.content)
}
)
}
@@ -0,0 +1,78 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { isFolderRepo } from '../../../shared/repo-kind'
import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports'
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { readFile, stat } from 'node:fs/promises'
import { isENOENT } from '../filesystem-path-containment'
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContext): void {
const { store } = context
ipcMain.handle(
'hooks:inspectSetupScriptImports',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return []
}
return inspectSetupScriptImportCandidates(
async (relativePath) => {
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
if (repo.connectionId) {
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return null
}
try {
const result = await fsProvider.readFile(filePath)
return result.isBinary ? null : result.content
} catch {
return null
}
}
try {
return await readFile(filePath, 'utf-8')
} catch (error) {
if (!isENOENT(error)) {
console.warn('[hooks] Failed to inspect setup script import candidate:', error)
}
return null
}
},
{
fileExists: async (relativePath) => {
const filePath = joinWorktreeRelativePath(repo.path, relativePath)
if (repo.connectionId) {
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return false
}
try {
const fileStat = await fsProvider.stat(filePath)
return fileStat.type !== 'directory'
} catch {
return false
}
}
try {
const fileStat = await stat(filePath)
return !fileStat.isDirectory()
} catch (error) {
if (!isENOENT(error)) {
console.warn('[hooks] Failed to stat setup script import candidate:', error)
}
return false
}
}
}
)
}
)
}
@@ -0,0 +1,29 @@
import { ipcMain } from 'electron'
import {
createIssueCommandRunnerScript,
resolveSetupRunnerShell
} from '../../worktree-runner-script'
import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
export function registerWorktreeHookRunnerHandler(context: WorktreeIpcContext): void {
const { store } = context
ipcMain.handle(
'hooks:createIssueCommandRunner',
(_event, args: { repoId: string; worktreePath: string; command: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
}
return createIssueCommandRunnerScript(
repo,
args.worktreePath,
args.command,
getLocalProjectWorktreeGitOptions(store, repo),
resolveSetupRunnerShell(store.getSettings())
)
}
)
}
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
import {
handleMock,
removeHandlerMock,
listWorktreesMock,
getPullRequestPushTargetMock,
@@ -14,6 +15,35 @@ import {
} from './worktrees-test-fixtures'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
const WORKTREE_HANDLER_CHANNELS = [
'worktrees:listAll',
'worktrees:list',
'worktrees:listRetiredNames',
'worktrees:listDetected',
'worktrees:listKnownForExecutionHost',
'worktrees:forgetRemovedForExecutionHost',
'worktrees:cancelListDetected',
'worktrees:create',
'worktrees:adoptProvisionedRoot',
'worktrees:prefetchCreateBase',
'worktrees:resolvePrBase',
'worktrees:resolveMrBase',
'worktrees:remove',
'worktrees:forgetLocal',
'worktrees:forceDeletePreservedBranch',
'worktrees:updateMeta',
'worktrees:listLineage',
'worktrees:listLineageForHost',
'worktrees:updateLineage',
'worktrees:persistSortOrder',
'worktrees:getBranchRenameFailureOutput',
'hooks:check',
'hooks:inspectSetupScriptImports',
'hooks:createIssueCommandRunner',
'hooks:readIssueCommand',
'hooks:writeIssueCommand'
] as const
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
@@ -113,6 +143,20 @@ describe('registerWorktreeHandlers', () => {
expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined()
})
it('removes exactly the installed channel set before installing the first handler', () => {
const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel)
const installedChannels = handleMock.mock.calls.map(([channel]) => channel)
expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
expect(new Set(removedChannels)).toEqual(new Set(installedChannels))
expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan(
Math.min(...handleMock.mock.invocationCallOrder)
)
})
it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => {
const liveId = 'repo-1::/workspace/repo'
const staleId = 'removed-repo::/workspace/gone'
@@ -3,7 +3,7 @@ import { resolve } from 'node:path'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import { notifyWorktreesChanged } from './worktree-remote'
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees'
import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees/listing/detected-worktree-scan-cache'
import { setPlatform, listWorktreesMock } from './worktrees-test-module-mocks'
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { makeWorktreeMeta } from './worktrees-test-fixtures'
@@ -156,6 +156,22 @@ describe('registerWorktreeHandlers', () => {
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
it('purges metadata after a rejected best-effort PTY sweep', async () => {
const worktreeId = 'repo-1::/workspace/feature-wt'
killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable'))
await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan(
mainWindow.webContents.send.mock.invocationCallOrder[0]
)
expect(removeWorktreeMock).not.toHaveBeenCalled()
})
it('sweeps a connected SSH owner through its PTY provider', async () => {
const worktreeId = 'repo-gone::/workspace/feature-wt'
const sshProvider = {} as never
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../shared/worktree/types'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees'
import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing'
import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority'
import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks'
import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness'
@@ -137,6 +137,35 @@ describe('registerWorktreeHandlers', () => {
)
})
it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => {
const worktreeId = 'repo-1::/workspace/feature-wt'
const localRepo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'local',
badgeColor: '#000',
addedAt: 0
}
const sshRepo = {
...localRepo,
displayName: 'ssh',
connectionId: 'conn-1'
}
store.getRepos.mockReturnValue([localRepo, sshRepo])
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' }))
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue({})
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' })
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled()
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => {
mockKnownFeatureWorktree()
getEffectiveHooksMock.mockReturnValue(null)
@@ -484,6 +513,29 @@ describe('registerWorktreeHandlers', () => {
})
})
it('purges a folder workspace after a rejected best-effort PTY sweep', async () => {
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
store.getRepo.mockReturnValue({
id: 'repo-folder',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable'))
await handlers['worktrees:remove'](null, { worktreeId })
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-folder'
})
expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan(
mainWindow.webContents.send.mock.invocationCallOrder[0]
)
})
// Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:<uuid>`
// — reusable across hosts — so the sweep must name the owning connection.
it('fences an SSH folder workspace PTY sweep to the owning connection', async () => {
@@ -3,7 +3,7 @@ import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees'
import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers'
import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority'
import { getSshGitProviderMock } from './worktrees-test-module-mocks'
import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness'
+2 -1
View File
@@ -1,6 +1,7 @@
import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
import { registerWorktreeHandlers } from './worktrees'
import { __resetDetectedWorktreeScanCacheForTests } from './worktrees/listing/detected-worktree-scan-cache'
import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories'
import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement'
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
+73 -3663
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import { randomUUID } from 'node:crypto'
import type { Repo } from '../../../../shared/repo-types'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection'
import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
import type { Store } from '../../../persistence/loading-store/store'
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model'
export function createFolderWorkspace(
args: CreateWorktreeArgsWithSystemProvenance,
repo: Repo,
store: Store
): CreateWorktreeResult {
const now = Date.now()
const instanceId = randomUUID()
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
const meta = store.setWorktreeMeta(worktreeId, {
instanceId,
...(store.getProjectHostSetups
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
: {}),
displayName: args.displayName || args.name,
lastActivityAt: now,
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
creatorProvenance: { kind: 'host' },
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}),
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}),
...(args.linkedAzureDevOpsPR !== undefined
? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR }
: {}),
...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}),
...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}),
...(args.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: args.linkedTaskSourceContext }
: {})
})
return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) }
}
@@ -0,0 +1,120 @@
import { ipcMain } from 'electron'
import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { gitExecFileAsync } from '../../../git/runner'
import {
getLocalProjectGitExecOptions,
getLocalProjectWorktreeGitOptions
} from '../../../project-runtime-git-options'
import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
import {
fetchPrHeadTrackingRef,
fetchGitHubPullRequestHeadRef
} from '../../../github/pr-head-tracking-ref'
import { resolveGitHubPrStartPoint } from '../../../github/pr-start-point'
import { resolveGitHubReviewHeadRemote } from '../../../github/review-head-remote'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerReviewBaseHandlers(context: WorktreeIpcContext): void {
const { store, runtime } = context
ipcMain.handle(
'worktrees:resolvePrBase',
async (
_event,
args: {
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}
): Promise<GitHubPrStartPoint | { error: string }> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
return { error: 'Repo not found' }
}
if (isFolderRepo(repo)) {
return { error: 'Folder mode does not support creating worktrees.' }
}
const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => {
if (!repo.connectionId) {
return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo))
}
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
throw new Error(
'SSH Git provider is not available. Reconnect to this target and try again.'
)
}
return provider.exec(args, repo.path)
}
// Why: SSH review-head fetches require narrow write-capable RPCs.
const fetchRemoteTrackingRef = (remote: string, branch: string): Promise<void> =>
fetchPrHeadTrackingRef(
repo,
repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined,
remote,
branch,
{ localGitExecOptions: getLocalProjectGitExecOptions(store, repo) }
)
const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise<string> =>
fetchGitHubPullRequestHeadRef(
repo,
repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined,
remote,
prNumber,
{ localGitExecOptions: getLocalProjectGitExecOptions(store, repo) }
)
return resolveGitHubPrStartPoint({
repoPath: repo.path,
prNumber: args.prNumber,
headRefName: args.headRefName,
baseRefName: args.baseRefName,
isCrossRepository: args.isCrossRepository,
issueSourcePreference: repo.issueSourcePreference,
connectionId: repo.connectionId ?? null,
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
gitExec,
fetchRemoteTrackingRef,
fetchPullRequestHeadRef,
// Why: one resolver keeps source preference and hosting identity aligned
// across local, WSL, and SSH worktree creation.
resolveRemote: () =>
resolveGitHubReviewHeadRemote({
repoPath: repo.path,
issueSourcePreference: repo.issueSourcePreference,
connectionId: repo.connectionId ?? null,
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
gitExec
})
})
}
)
ipcMain.handle(
'worktrees:resolveMrBase',
async (
_event,
args: {
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}
): Promise<
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
| { error: string }
> => {
return runtime.resolveManagedMrBase({
repoSelector: `id:${args.repoId}`,
mrIid: args.mrIid,
sourceBranch: args.sourceBranch,
targetBranch: args.targetBranch,
isCrossRepository: args.isCrossRepository
})
}
)
}
@@ -0,0 +1,156 @@
import { ipcMain, app } from 'electron'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
AdoptProvisionedRootArgs
} from '../../../../shared/worktree/create-types'
import { withWorktreeSpan } from '../../../observability/instrumentation'
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
import type { WorkspaceSource } from '../../../../shared/telemetry-events'
import {
resolveAutomationWorkspaceProvenance,
releaseAutomationWorkspaceProvenanceRequest,
finishAutomationWorkspaceProvenanceRequest
} from '../../../automations/workspace-provenance'
import { isFolderRepo } from '../../../../shared/repo-kind'
import {
createRemoteWorktree,
createLocalWorktree,
notifyWorktreesChanged
} from '../../worktree-remote'
import { track } from '../../../telemetry/client'
import { classifyWorkspaceCreateError } from '../../workspace-create-error-classifier'
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import { adoptProvisionedRootSshCheckout } from '../../../provisioned-root-ssh-adoption'
import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas'
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
import { createFolderWorkspace } from './folder-workspace-creation'
import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void {
const { mainWindow, store, runtime, options } = context
ipcMain.handle(
'worktrees:create',
async (_event, rawArgs: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
const args = normalizeLinkedWorkItemFields(rawArgs)
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
return withWorktreeSpan({ stage: 'create' }, async () => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
}
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: args.repoId,
repo,
request: args.automationProvenanceRequest
})
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
...args,
automationProvenance
}
let result: CreateWorktreeResult
try {
// Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks.
result = isFolderRepo(repo)
? createFolderWorkspace(createArgs, repo, store)
: repo.connectionId
? await createRemoteWorktree(createArgs, repo, store, mainWindow)
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
track('workspace_create_failed', {
source,
error_class: classifyWorkspaceCreateError(error),
...getCohortAtEmit()
})
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
source,
from_existing_branch:
!isFolderRepo(repo) &&
typeof args.baseBranch === 'string' &&
args.baseBranch.length > 0,
...getCohortAtEmit()
})
if (isFolderRepo(repo)) {
notifyWorktreesChanged(mainWindow, repo.id)
}
options?.onWorktreeLifecycle?.({
kind: 'created',
worktreeId: result.worktree.id,
path: result.worktree.path,
branch: result.worktree.branch
})
return result
})
}
)
ipcMain.handle(
'worktrees:adoptProvisionedRoot',
async (_event, rawArgs: AdoptProvisionedRootArgs): Promise<CreateWorktreeResult> => {
const args = normalizeLinkedWorkItemFields(rawArgs)
return withWorktreeSpan({ stage: 'create' }, async () => {
const repo = findExactRepoOwner(store, args.repoId, args.executionHostId)
if (!repo || isFolderRepo(repo)) {
throw new Error('Provisioned-root repository ownership is missing or ambiguous.')
}
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: args.repoId,
repo,
request: args.automationProvenanceRequest
})
let result: CreateWorktreeResult
try {
result = await adoptProvisionedRootSshCheckout({
userDataPath: app.getPath('userData'),
request: { ...args, automationProvenance },
repo,
store,
isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId)
})
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
track('workspace_create_failed', {
source,
error_class: classifyWorkspaceCreateError(error),
...getCohortAtEmit()
})
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
track('workspace_created', {
source,
from_existing_branch: false,
...getCohortAtEmit()
})
notifyWorktreesChanged(mainWindow, repo.id)
options?.onWorktreeLifecycle?.({
kind: 'created',
worktreeId: result.worktree.id,
path: result.worktree.path,
branch: result.worktree.branch
})
return result
})
}
)
}
@@ -0,0 +1,22 @@
import { ipcMain } from 'electron'
import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void {
const { store, runtime } = context
ipcMain.handle(
'worktrees:prefetchCreateBase',
async (_event, args: { repoId: string; baseBranch?: string }): Promise<void> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
return
}
try {
await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime })
} catch {
// Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there.
}
}
)
}
@@ -0,0 +1,75 @@
import { randomUUID } from 'node:crypto'
import type { Repo } from '../../../shared/repo-types'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type { Worktree } from '../../../shared/worktree/types'
export function getFolderWorkspaceRootId(repo: Repo): string {
return `${repo.id}::${repo.path}`
}
export function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
}
export function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
}
export function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
const rootId = getFolderWorkspaceRootId(repo)
return (
worktreeId === rootId ||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
)
}
export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
return {
id: worktreeId,
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId: repo.id,
...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}),
...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}),
...(meta.projectHostSetupId !== undefined
? { projectHostSetupId: meta.projectHostSetupId }
: {}),
path: repo.path,
head: '',
branch: '',
isBare: false,
isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo),
displayName: meta.displayName || repo.displayName,
comment: meta.comment || '',
linkedIssue: meta.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
linkedBitbucketPR: meta.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: meta.linkedGiteaPR ?? null,
linkedWorkItem: meta.linkedWorkItem ?? null,
linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null,
isArchived: meta.isArchived ?? false,
isUnread: meta.isUnread ?? false,
isPinned: meta.isPinned ?? false,
sortOrder: meta.sortOrder ?? 0,
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
lastActivityAt: meta.lastActivityAt ?? 0,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}),
...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments,
mobileDiffReview: meta.mobileDiffReview
}
}

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