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

This commit is contained in:
OrcaWin
2026-08-03 11:11:14 -07:00
committed by GitHub
parent 128e3e335e
commit cd68a8b00c
31 changed files with 3941 additions and 769 deletions
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(30)
expect(PROTOCOL_VERSION).toBe(31)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -13,5 +13,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30)
})
})
@@ -6,6 +6,7 @@ import {
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact
@@ -13,7 +14,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(30)
expect(PROTOCOL_VERSION).toBe(31)
expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31)
expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30)
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
@@ -21,7 +23,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 29 }, (_, index) => index + 1)
Array.from({ length: 30 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v30 transfers large cold-restore seeds across bounded NDJSON messages.
export const PROTOCOL_VERSION = 30
// v31 exposes attach-only PTY adoption so a mount cannot create over a live stable pane.
export const PROTOCOL_VERSION = 31
export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
@@ -22,7 +23,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29
28, 29, 30
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
@@ -1411,6 +1411,69 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.snapshot).toBeUndefined()
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
it('forwards attach-only and never creates an absent stable session', async () => {
const subprocessBeforeAttach = lastSubprocess
await expect(
adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(lastSubprocess).toBe(subprocessBeforeAttach)
})
it('does not inspect cold history for attach-only ownership checks', async () => {
const historyDir = join(dir, 'attach-only-history')
const historyAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
historyPath: historyDir
})
const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader
const probe = vi.spyOn(reader, 'probeRestorableHistory')
const getAppliedSize = vi.spyOn(historyAdapter, 'getAppliedSize')
try {
await expect(
historyAdapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-attach-only-history-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-attach-only-history-session')
expect(probe).not.toHaveBeenCalled()
expect(getAppliedSize).not.toHaveBeenCalled()
} finally {
historyAdapter.dispose()
}
})
it('fails closed before dispatching attach-only to a v30 daemon', async () => {
const ensureConnected = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const request = vi.spyOn(DaemonClient.prototype, 'request')
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
await expect(
legacy.spawn({
cols: 80,
rows: 24,
sessionId: 'legacy-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('terminal_pane_owner_unknown')
expect(request).not.toHaveBeenCalledWith('createOrAttach', expect.anything())
} finally {
legacy.dispose()
request.mockRestore()
ensureConnected.mockRestore()
}
})
})
describe('attach', () => {
+11 -2
View File
@@ -35,7 +35,10 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import {
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
} from './daemon-protocol-version'
import {
isAgentSessionClaimedSpawnResult,
isAgentSessionOwnerBinding,
@@ -331,6 +334,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
if (opts.attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) {
throw new Error('terminal_pane_owner_unknown')
}
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
@@ -449,7 +455,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
const historyProbe = this.historyReader?.probeRestorableHistory(sessionId)
const historyProbe = opts.attachOnly
? undefined
: this.historyReader?.probeRestorableHistory(sessionId)
if (historyProbe && historyProbe.status !== 'none') {
if ((await this.getAppliedSize(sessionId)) !== null) {
restoreSkippedForLiveSession = true
@@ -496,6 +504,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
launchAgent: opts.launchAgent,
...(opts.attachOnly ? { attachOnly: true } : {}),
// Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider.
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonClient } from './client'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle {
let onExit: ((code: number) => void) | undefined
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((callback) => {
onExit = callback
}),
dispose: vi.fn()
}
}
describe('DaemonServer attach-only preparation', () => {
const servers: DaemonServer[] = []
const clients: DaemonClient[] = []
const directories: string[] = []
afterEach(async () => {
for (const client of clients.splice(0)) {
client.disconnect()
}
await Promise.all(servers.splice(0).map((server) => server.shutdown()))
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
it('skips fresh-spawn preparation when attaching only', async () => {
const directory = mkdtempSync(join(tmpdir(), 'daemon-attach-only-test-'))
directories.push(directory)
const socketPath = getDaemonSocketPath(directory)
const tokenPath = join(directory, 'test.token')
const preparePtySpawn = vi.fn(async () => {})
const server = new DaemonServer({
socketPath,
tokenPath,
preparePtySpawn,
spawnSubprocess: () => createMockSubprocess()
})
servers.push(server)
await server.start()
const client = new DaemonClient({ socketPath, tokenPath })
clients.push(client)
await client.ensureConnected()
await client.request('createOrAttach', {
sessionId: 'stable-pane-session',
cols: 80,
rows: 24
})
await expect(
client.request('createOrAttach', {
sessionId: 'stable-pane-session',
cols: 120,
rows: 40,
attachOnly: true
})
).resolves.toMatchObject({ isNew: false })
expect(preparePtySpawn).toHaveBeenCalledOnce()
})
})
+4 -1
View File
@@ -733,7 +733,9 @@ export class DaemonServer {
) {
throw new Error('agent_session_identity_required')
}
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
if (!p.attachOnly) {
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
}
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
throw new Error('Multiple terminal history seed sources')
}
@@ -752,6 +754,7 @@ export class DaemonServer {
envToDelete: p.envToDelete,
command: p.command,
startupCommandDelivery: p.startupCommandDelivery,
...(p.attachOnly === true ? { attachOnly: true } : {}),
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
shellOverride: p.shellOverride,
@@ -4,7 +4,6 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos
export type InternalCreateOrAttachOptions = CreateOrAttachOptions & {
agentSessionGeneration?: string
attachOnly?: boolean
}
export async function createOrAttachClaimedAgentSession(args: {
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { SubprocessHandle } from './session'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
describe('TerminalHost attach-only sessions', () => {
let host: TerminalHost
let spawnSubprocess: Mock<SpawnSubprocess>
beforeEach(() => {
spawnSubprocess = vi.fn<SpawnSubprocess>(() => {
let onExit: ((code: number) => void) | undefined
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((callback) => {
onExit = callback
}),
dispose: vi.fn()
} as SubprocessHandle
})
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
await host.dispose()
})
it('attaches only to an existing stable session', async () => {
await host.createOrAttach({
sessionId: 'stable-pane-session',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const result = await host.createOrAttach({
sessionId: 'stable-pane-session',
cols: 120,
rows: 40,
attachOnly: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(result.isNew).toBe(false)
expect(spawnSubprocess).toHaveBeenCalledOnce()
})
it('does not create when an attach-only stable session is absent', async () => {
await expect(
host.createOrAttach({
sessionId: 'missing-stable-pane-session',
cols: 80,
rows: 24,
attachOnly: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(spawnSubprocess).not.toHaveBeenCalled()
})
})
@@ -19,6 +19,8 @@ export type CreateOrAttachOptions = {
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
/** Missing ownership is not permission to create during stable-pane adoption. */
attachOnly?: boolean
/** Explicit shell the renderer asked for, forwarded to the subprocess. */
shellOverride?: string
terminalWindowsWslDistro?: string | null
+2
View File
@@ -68,6 +68,8 @@ export type CreateOrAttachRequest = {
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
/** Rejects an absent session instead of interpreting mount uncertainty as create permission. */
attachOnly?: boolean
/** Explicit Windows shell override selected by the user (e.g. 'wsl.exe').
* The daemon forwards this to its subprocess spawner so each tab honors
* the shell picked in the "+" menu or the persisted default-shell setting,
+980 -42
View File
File diff suppressed because it is too large Load Diff
+1070 -457
View File
File diff suppressed because it is too large Load Diff
@@ -258,6 +258,55 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing stable session', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: 'stable-pane-session',
attachOnly: true
})
expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true })
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when an attach-only stable session is absent', async () => {
await expect(
provider.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing numeric provider session', async () => {
const first = await provider.spawn({ cols: 80, rows: 24 })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: first.id,
attachOnly: true
})
expect(result).toMatchObject({ id: first.id, isReattach: true })
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when a numeric attach-only provider session is absent', async () => {
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true })
).rejects.toThrow('Session not found: 404')
expect(spawnMock).not.toHaveBeenCalled()
})
it('keeps a native UNC session native on a conflicting WSL reattach', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const first = await provider.spawn({
+9 -3
View File
@@ -369,9 +369,12 @@ function cancelAllPendingLocalPtySpawns(): void {
/**
* Normalizes renderer session ids that should be reused for local PTY reattach.
*/
function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null {
function normalizeLocalCallerSessionId(
sessionId: string | undefined,
allowNumeric = false
): string | null {
const requested = sessionId?.trim()
if (!requested || /^\d+$/.test(requested)) {
if (!requested || (!allowNumeric && /^\d+$/.test(requested))) {
return null
}
return requested
@@ -525,7 +528,7 @@ export class LocalPtyProvider implements IPtyProvider {
* Windows launches can pre-deliver startup commands in argv, so the stdin fallback only runs when needed.
*/
async spawn(args: PtySpawnOptions): Promise<PtySpawnResult> {
const reattachId = normalizeLocalCallerSessionId(args.sessionId)
const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true)
if (reattachId) {
const pendingShutdown = ptyShutdownOperations.get(reattachId)
if (pendingShutdown) {
@@ -536,6 +539,9 @@ export class LocalPtyProvider implements IPtyProvider {
return existing
}
}
if (args.attachOnly) {
throw new Error(`Session not found: ${args.sessionId ?? ''}`)
}
const id = allocatePtyId(reattachId ?? undefined)
const incarnationId = randomUUID()
@@ -62,6 +62,8 @@ export type PtySpawnOptions = {
* Existing-session attach paths must stay false so recovery checks do not
* replace the daemon out from under a still-live PTY. */
isNewSession?: boolean
/** Attach the named session atomically or fail without creating a process. */
attachOnly?: boolean
/** Why: allows the renderer to request a specific shell for a single new
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
* changing the user's persistent default shell setting. Only consulted on
+103
View File
@@ -23064,6 +23064,109 @@ describe('OrcaRuntimeService', () => {
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
})
it('preserves host metadata when terminal.create adopts a stable pane owner', async () => {
const adoptStablePane = vi.fn().mockResolvedValue(null)
const spawn = vi.fn(async (opts: { adoptedStablePane?: { owner: { handle?: string } } }) =>
opts.adoptedStablePane
? {
id: 'pty-stable-owner',
isReattach: true,
stablePaneOwner: {
handle: opts.adoptedStablePane.owner.handle!,
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID
}
}
: { id: 'pty-stable-owner' }
)
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
adoptStablePane,
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const first = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Original owner',
launchAgent: 'claude'
})
adoptStablePane.mockResolvedValueOnce({
result: { id: 'pty-stable-owner', isReattach: true },
owner: {
handle: first.handle,
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'pty-stable-owner'
}
})
const adopted = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'stable-owner-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Replacement intent',
command: "claude 'replacement'",
launchAgent: 'claude'
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(adopted).toMatchObject({
handle: first.handle,
ptyId: 'pty-stable-owner',
title: 'Original owner',
isReattach: true
})
expect(listed.tabs).toEqual([
expect.objectContaining({
parentTabId: 'stable-owner-tab',
title: 'Original owner',
launchAgent: 'claude'
})
])
expect(spawn.mock.calls[1]?.[0]).toMatchObject({
command: "claude 'replacement'",
adoptedStablePane: expect.anything()
})
expect(spawn.mock.calls[1]?.[0]).not.toMatchObject({
command: expect.stringContaining('--teammate-mode')
})
})
it('releases a stable-pane claim when creation aborts before provider spawn', async () => {
const releaseClaim = vi.fn()
const spawn = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
claimStablePaneCreate: vi.fn(() => releaseClaim),
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const abort = new AbortController()
abort.abort()
await expect(
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'aborted-stable-pane',
leafId: HEADLESS_LEAF_ID,
signal: abort.signal
})
).rejects.toThrow('client_disconnected')
expect(spawn).not.toHaveBeenCalled()
expect(releaseClaim).toHaveBeenCalledOnce()
})
it('publishes the hook provider session on a headless mobile tab so native chat can address the transcript', async () => {
const paneKey = makePaneKey('claude-tab', HEADLESS_LEAF_ID)
const providerSession = {
+320 -218
View File
@@ -527,6 +527,7 @@ import type {
IFilesystemProvider,
IPtyProvider,
PtyProcessInfo,
PtySpawnResult,
PtyTransientFact
} from '../providers/types'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
@@ -1531,6 +1532,32 @@ type HeadlessSeedMetadata = {
}
type RuntimePtyController = {
claimStablePaneCreate?(args: {
worktreeId: string
connectionId: string | null
tabId: string
leafId: string
}): () => void
adoptStablePane?(opts: {
cols: number
rows: number
cwd?: string
connectionId: string | null
worktreeId: string
preAllocatedHandle: string
tabId: string
leafId: string
}): Promise<{
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
} | null>
spawn?(opts: {
cols: number
rows: number
@@ -1558,10 +1585,22 @@ type RuntimePtyController = {
agentSessionCreateOperationId?: string
signal?: AbortSignal
onPtySpawnCommitted?: () => void
adoptedStablePane?: {
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
}
}): Promise<{
id: string
incarnationId?: PtyIncarnationId
wslDistro?: string
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}>
write(ptyId: string, data: string): boolean
@@ -15756,6 +15795,7 @@ export class OrcaRuntimeService {
tabId: parsed?.tabId ?? record?.tabId ?? '',
leafId: parsed?.leafId ?? record?.leafId ?? '',
ptyId: record?.ptyId ?? null,
connected: pty?.connected === true,
...(worktreeId ? { worktreeId } : {}),
...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null)
}
@@ -24440,236 +24480,298 @@ export class OrcaRuntimeService {
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID()
let paneKey = makePaneKey(tabId, leafId)
const launchToken = launchOpts.launchConfig
? (launchOpts.launchToken ?? randomUUID())
: undefined
const baseEnv = {
...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsSourceCommand =
launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
launchOpts.launchConfig,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
launchOpts.command &&
claudeAgentTeamsSourceCommand !== launchOpts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
launchOpts.launchConfig && agentTeamsPlan
? {
...launchOpts.launchConfig,
agentCommand: launchOpts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
...launchOpts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
: launchOpts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
{
...baseEnv,
...(sequencedStartupCommand
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd,
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: mergeTerminalEnvDeletionKeys(
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
worktreeId: workspace.id,
preAllocatedHandle,
connectionId: workspace.connectionId,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
agentSessionEnsure: {
claim: launchOpts.agentSessionClaim,
surface: {
worktreeId: workspace.id,
tabId,
leafId,
terminalHandle: preAllocatedHandle
}
}
}
: {}),
...(launchOpts.agentSessionCreateOperationId
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
: {}),
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null
? { persistHostSessionBinding: true }
: {})
leafId
})
reportPtySpawnCommitted()
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = makePaneKey(tabId, leafId)
let stablePaneCreateReleased = false
const releaseStablePaneCreate = (): void => {
if (stablePaneCreateReleased) {
return
}
stablePaneCreateReleased = true
claimedStablePaneCreate?.()
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (launchOpts.persistHostSessionBinding) {
pty.runtimeSessionOwned = true
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
cols: 120,
rows: 40,
cwd,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId
})
const launchToken = launchOpts.launchConfig
? (launchOpts.launchToken ?? randomUUID())
: undefined
const baseEnv = {
...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
const claudeAgentTeamsSourceCommand =
launchOpts.claudeAgentTeamsSourceCommand?.trim() ||
launchOpts.command?.trim() ||
undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
launchOpts.launchConfig,
claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
let agentTeamsPlan: Awaited<ReturnType<typeof buildClaudeAgentTeamsLaunchPlan>> | undefined
try {
agentTeamsPlan = adoptedBeforeLaunch
? undefined
: await buildClaudeAgentTeamsLaunchPlan({
command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
} catch (error) {
releaseStablePaneCreate?.()
throw error
}
pty.tabId = tabId
pty.paneKey = paneKey
pty.launchConfig = effectiveLaunchConfig
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchAgent = launchOpts.launchAgent ?? null
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
const sequencedStartupCommand =
agentTeamsPlan &&
claudeAgentTeamsSourceCommand &&
launchOpts.command &&
claudeAgentTeamsSourceCommand !== launchOpts.command
? agentTeamsPlan.command
: undefined
const effectiveLaunchConfig =
launchOpts.launchConfig && agentTeamsPlan
? {
...launchOpts.launchConfig,
agentCommand: launchOpts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
...launchOpts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
: launchOpts.launchConfig
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
// Claude Agent Teams runs. Preserve the direct Claude command separately
// so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
{
...baseEnv,
...(sequencedStartupCommand
? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const persistHostSessionBinding =
launchOpts.persistHostSessionBinding ||
launchOpts.surfaceOwner === false ||
this.getAvailableAuthoritativeWindow() === null
let result: Awaited<ReturnType<NonNullable<RuntimePtyController['spawn']>>>
try {
result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd,
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: mergeTerminalEnvDeletionKeys(
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
agentSessionEnsure: {
claim: launchOpts.agentSessionClaim,
surface: {
worktreeId: workspace.id,
tabId,
leafId,
terminalHandle: preAllocatedHandle
}
}
}
: {}),
...(launchOpts.agentSessionCreateOperationId
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
: {}),
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
...(launchOpts.onPtySpawnCommitted
? { onPtySpawnCommitted: reportPtySpawnCommitted }
: {}),
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
// Why: a headless-created pane has no renderer session writer. Persist
// its tab/leaf binding at spawn so a later promoted window reattaches
// the live daemon or SSH PTY instead of replacing it with a fresh one.
// Re-check freshly: the entry-time snapshot can go stale across the
// awaits above if the authoritative window is destroyed mid-spawn.
...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
})
} finally {
releaseStablePaneCreate?.()
}
if (!result.stablePaneOwner) {
reportPtySpawnCommitted()
}
const adoptedStablePane = Boolean(result.stablePaneOwner)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
paneKey = makePaneKey(tabId, leafId)
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
// failing here must not strand a live process without returning a handle.
// Pass the pre-minted tabId so the renderer adopts under the same id
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = createTerminalRevealWarning(handle, err)
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (persistHostSessionBinding) {
pty.runtimeSessionOwned = true
}
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.launchConfig = effectiveLaunchConfig
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
} else if (presentation !== 'background') {
warning = createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(warning ? { warning } : {})
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
// Why: explicit background presentation may carry legacy activate
// metadata from an already-owned renderer pane; don't select it on mobile.
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
// Why: after the PTY is spawned, renderer tab adoption is best-effort;
// failing here must not strand a live process without returning a handle.
// Pass the pre-minted tabId so the renderer adopts under the same id
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = createTerminalRevealWarning(handle, err)
}
} else if (presentation !== 'background') {
warning = createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(warning ? { warning } : {})
}
} finally {
releaseStablePaneCreate()
}
}
@@ -297,6 +297,7 @@ function resolveMockPaneWindowsShiftEnterEncoding(
}
type ConnectCallbacks = {
onReattachDetermined?: () => void
onConnect?: () => void
onData?: (
data: string,
@@ -5479,6 +5480,62 @@ describe('connectPanePty', () => {
expect(notifyCodexPaneBoundForStaleSweep).toHaveBeenCalledWith('pty-daemon-reattach')
})
it('replays a stable-pane adoption without submitting the SSH resume command', async () => {
const { connectPanePty } = await import('./pty-connection')
const stablePtyId = toAppSshPtyId('conn-1', 'stable-pane-session')
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }) => {
callbacks.onReattachDetermined?.()
transport.getPtyId.mockReturnValue(stablePtyId)
callbacks.onData?.('NEWER-LIVE-SSH-OUTPUT')
return {
id: stablePtyId,
isReattach: true,
replay: 'ORIGINAL-LIVE-SSH-OUTPUT'
}
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]])
}
const pane = createPane(1)
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
startup: { command: 'codex resume provider-session' }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(4)
if (!onDataHandler || parseCallbacks.length === 0) {
throw new Error('expected replay and terminal input handlers')
}
;(onDataHandler as (data: string) => void)('DURING_ADOPTION_REPLAY\r')
expect(transport.sendInput).not.toHaveBeenCalledWith('DURING_ADOPTION_REPLAY\r')
for (let step = 0; step < 30; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
;(onDataHandler as (data: string) => void)('AFTER_ADOPTION_REPLAY\r')
expect(pane.container.dataset.ptyId).toBe(stablePtyId)
expect(writes.join('')).toContain('ORIGINAL-LIVE-SSH-OUTPUT')
expect(writes.join('').indexOf('ORIGINAL-LIVE-SSH-OUTPUT')).toBeLessThan(
writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT')
)
expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r')
expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId)
})
it('drops xterm onData while pane is replaying restored bytes', async () => {
// Regression: during replay, xterm auto-replies to embedded queries (DA1/DECRQM/OSC/CPR) via onData must not reach transport.sendInput or they land as stray chars on the prompt. See replay-guard.ts.
const { connectPanePty } = await import('./pty-connection')
@@ -10961,16 +11018,22 @@ describe('connectPanePty', () => {
expect(transport.sendInput).not.toHaveBeenCalled()
})
it('renders the reattach snapshot before live bytes delivered during the spawn reply', async () => {
it('drains live bytes after transport confirms an explicit reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const { deliverTerminalDataWithDeferredCredit } =
await import('@/lib/pane-manager/terminal-delivery-credit')
const transport = createMockTransport('tab-pty')
const acknowledgeLiveFrame = vi.fn()
transport.connect.mockImplementation(
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (!sessionId) {
return null
}
// Why: the real dispatcher drains post-snapshot bytes as soon as spawn IPC resolves, before connect() returns.
callbacks?.onData?.('post-snapshot-live')
callbacks?.onReattachDetermined?.()
deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => {
callbacks?.onData?.('post-snapshot-live')
})
return { id: sessionId, snapshot: 'authoritative-snapshot' }
}
)
@@ -10988,13 +11051,14 @@ describe('connectPanePty', () => {
const snapshotIndex = writes.indexOf('authoritative-snapshot')
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
expect(writes).not.toContain('post-snapshot-live')
while (parseCallbacks.length > 0) {
for (let step = 0; step < 40; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
await flushAsyncTicks(8)
const liveIndex = writes.indexOf('post-snapshot-live')
expect(liveIndex).toBeGreaterThan(snapshotIndex)
expect(acknowledgeLiveFrame).toHaveBeenCalledOnce()
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty')
})
it('re-enforces follow intent after deferred reattach live output parses', async () => {
@@ -5167,6 +5167,7 @@ export function connectPanePty(
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
.then(async (spawnedPtyId) => {
if (outputCallbacks.generation !== transportStreamGeneration) {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
@@ -5180,8 +5181,30 @@ export function connectPanePty(
? spawnedPtyId
: transport.getPtyId()
if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
return null
}
const connectResult =
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
? spawnedPtyId
: null
if (connectResult?.isReattach) {
pendingStartupCommand = null
const accepted = await handleReattachResult(
connectResult,
null,
coldRestoreOverride,
outputCallbacks.generation
)
finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
const gen = await preSignalPromise
if (accepted && resolvedPtyId && typeof gen === 'number') {
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
} else if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
}
return accepted ? resolvedPtyId : null
}
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
@@ -5251,9 +5274,11 @@ export function connectPanePty(
if (resolvedPtyId && connectionId) {
schedulePendingStartupCommandDelivery()
}
finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation)
return resolvedPtyId
})
.catch(async () => {
finishReattachLiveDataDeferral(false, outputCallbacks.generation)
if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) {
clearRegisteredStartupLaunchConfig()
}
@@ -5683,6 +5708,11 @@ export function connectPanePty(
return {
generation,
callbacks: {
onReattachDetermined: (): void => {
if (isCurrent()) {
beginReattachLiveDataDeferralIfUnowned(generation)
}
},
onConnect: (): void => {
if (isCurrent()) {
reportRemoteRendererSerializerReady()
@@ -7591,6 +7621,14 @@ export function connectPanePty(
}
}
const beginReattachLiveDataDeferralIfUnowned = (
ownerGeneration = transportStreamGeneration
): void => {
if (!deferredReattachLiveDataOwners.has(ownerGeneration)) {
beginReattachLiveDataDeferral(ownerGeneration)
}
}
const finishReattachLiveDataDeferral = (
deliver: boolean,
acceptedGeneration = transportStreamGeneration
@@ -72,6 +72,8 @@ export type PtyConnectResult = {
}
type PtyCallbacks = {
/** Called before an adopted PTY can publish buffered/live bytes. */
onReattachDetermined?: () => void
onConnect?: () => void
onDisconnect?: () => void
onData?: (data: string, meta?: PtyDataMeta) => void
@@ -133,6 +133,41 @@ describe('createIpcPtyTransport', () => {
expect(transport.isConnected()).toBe(false)
})
it('announces a daemon adoption before publishing its buffered PTY data', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
spawn.mockResolvedValueOnce({ id: 'adopted-pty', isReattach: true })
const order: string[] = []
const transport = createIpcPtyTransport({})
const connecting = transport.connect({
url: '',
callbacks: {
onReattachDetermined: () => order.push('adopt'),
onData: () => order.push('data')
}
})
onData?.({ id: 'adopted-pty', data: 'buffered' })
await connecting
expect(order).toEqual(['adopt', 'data'])
})
it('does not reannounce an explicit reattach already owned by its caller', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
spawn.mockResolvedValueOnce({ id: 'restored-pty', isReattach: true })
const onReattachDetermined = vi.fn()
const transport = createIpcPtyTransport({})
await transport.connect({
url: '',
sessionId: 'restored-pty',
callbacks: { onReattachDetermined }
})
expect(onReattachDetermined).not.toHaveBeenCalled()
})
it('forwards requested environment deletions to the PTY spawn', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
@@ -2516,6 +2551,83 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
})
it('reports a host stable-pane adoption as reattach without fresh-spawn ownership', async () => {
runtimeCall.mockResolvedValue({
id: 'rpc-create',
ok: true,
result: {
terminal: {
handle: 'term-original',
worktreeId: 'repo1::/remote/wt',
title: 'Original',
surface: 'background',
isReattach: true
}
},
_meta: { runtimeId: 'runtime-remote' }
})
const onPtySpawn = vi.fn()
const onReattachDetermined = vi.fn()
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
onPtySpawn
})
const result = await transport.connect({
url: '',
callbacks: { onReattachDetermined }
})
expect(result).toEqual({
id: 'remote:env-1@@term-original',
replay: '',
isReattach: true
})
expect(onReattachDetermined).toHaveBeenCalledOnce()
expect(onPtySpawn).not.toHaveBeenCalled()
})
it('does not close an adopted stable-pane owner when create resolves after destroy', async () => {
let resolveCreate!: (value: unknown) => void
runtimeCall.mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve
})
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
})
const connecting = transport.connect({ url: '', callbacks: {} })
transport.destroy?.()
resolveCreate({
id: 'rpc-create',
ok: true,
result: {
terminal: {
handle: 'term-original',
worktreeId: 'repo1::/remote/wt',
title: 'Original',
surface: 'background',
isReattach: true
}
},
_meta: { runtimeId: 'runtime-remote' }
})
await connecting
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.close' })
)
})
it('suspends passive remote output until host sleep is cancelled', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const { applyHostWorktreeTerminalSleepState } = await import('./pty-shutdown-exit-deferral')
@@ -822,6 +822,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return spawnResult
}
if (spawnResult.isReattach && !admittedSessionId) {
storedCallbacks.onReattachDetermined?.()
}
ptyId = spawnResult.id
connected = true
@@ -430,7 +430,7 @@ export function createRemoteRuntimePtyTransport(
result: RemoteAgentSessionLaunchResult,
environmentId: string
): boolean {
if (result.disposition !== undefined) {
if (result.disposition !== undefined || result.terminal.isReattach === true) {
// Why: every structured launch is host-owned; provisional teardown must
// never close its canonical terminal while snapshot reconciliation catches up.
return true
@@ -1968,6 +1968,9 @@ export function createRemoteRuntimePtyTransport(
}
handle = createdTerminal.handle
if (createdTerminal.isReattach === true) {
storedCallbacks.onReattachDetermined?.()
}
remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId)
registerShutdownHandlers(remotePtyId)
connected = true
@@ -1975,7 +1978,9 @@ export function createRemoteRuntimePtyTransport(
cols: options.cols ?? 80,
rows: options.rows ?? 24
}
onPtySpawn?.(remotePtyId)
if (createdTerminal.isReattach !== true) {
onPtySpawn?.(remotePtyId)
}
emitRecoveryState()
try {
@@ -1991,7 +1996,8 @@ export function createRemoteRuntimePtyTransport(
return {
id: remotePtyId,
replay: ''
replay: '',
...(createdTerminal.isReattach === true ? { isReattach: true } : {})
} satisfies PtyConnectResult
} catch (error) {
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
@@ -19,14 +19,27 @@ const retryByPane = new WeakMap<ManagedPane, RetryState>()
function scheduleRetryTick(run: () => void): RetrySchedule {
if (typeof requestAnimationFrame === 'function') {
let cancelled = false
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const finish = (): void => {
if (cancelled || settled) {
return
}
settled = true
run()
}
const rafId = requestAnimationFrame(() => {
if (!cancelled) {
if (!cancelled && !settled) {
// Why: FitAddon must observe committed CSS, and synchronous rAF test
// shims must not recursively consume the whole retry budget inline.
timer = setTimeout(run, LAYOUT_SETTLE_MS)
if (timer !== null) {
clearTimeout(timer)
}
timer = setTimeout(finish, LAYOUT_SETTLE_MS)
}
})
// Why: Chromium can indefinitely throttle rAF for a hidden Electron window; the fit budget must still release deferred PTY output.
timer = setTimeout(finish, LAYOUT_SETTLE_MS * 2)
return {
cancel: () => {
cancelled = true
@@ -185,6 +185,19 @@ describe('safeFitAndThen unmeasurable-pane retry', () => {
safeFit(pane)
expect(continuation).not.toHaveBeenCalled()
})
it('resolves failure when hidden-window animation frames are withheld', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
retryIfUnmeasurable: true
})
await vi.advanceTimersByTimeAsync(40 * 32)
expect(continuation).not.toHaveBeenCalled()
await expect(handle.completion).resolves.toBe(false)
})
})
describe('paneFitClientSizeChanged (reveal fit gate)', () => {
@@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 30,
"daemonProtocolVersion": 31,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30
27, 28, 29, 30, 31
]
}
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 30,
daemonProtocolVersion: 31,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30
27, 28, 29, 30, 31
]
} as const
+3
View File
@@ -659,6 +659,8 @@ export type RuntimeTerminalCreate = {
warning?: string
/** Present only for the structured host-authority resume path. */
agentSessionDisposition?: 'created' | 'adopted'
/** The host attached this request to the existing stable pane owner. */
isReattach?: true
}
export type RuntimeTerminalSplit = {
@@ -672,6 +674,7 @@ export type RuntimeTerminalResolvePane = {
tabId: string
leafId: string
ptyId: string | null
connected?: boolean
worktreeId?: string
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform