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
+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()
}
}