Preserve agent settings when sleeping sessions resume (#5916)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-21 16:44:58 -07:00
committed by GitHub
co-authored by Orca
parent a856c7d6b6
commit fe8fe16c83
100 changed files with 3935 additions and 443 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin
// active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read
// and POST or CJK prompts arrive in Orca as literal question marks. Timeout
// caps best-effort hook posts if the local listener stalls.
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/${source}') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/${source}') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
}
export function removeManagedCommands(
+4
View File
@@ -215,6 +215,7 @@ function sanitizeHydratedEntry(
}
return {
paneKey,
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
@@ -233,6 +234,7 @@ function sanitizeHydratedEntry(
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
return {
paneKey: entry.paneKey,
...(entry.launchToken ? { launchToken: entry.launchToken } : {}),
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
@@ -980,6 +982,7 @@ export class AgentHookServer {
worktreeId?: string
env?: string
version?: string
launchToken?: string
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
@@ -1082,6 +1085,7 @@ export class AgentHookServer {
})
const event: AgentHookEventPayload = {
paneKey,
launchToken: envelope.launchToken,
tabId,
worktreeId,
connectionId: trimmedConnectionId,
+1
View File
@@ -232,6 +232,7 @@ function getAmpPluginSource(): string {
' },',
' body: JSON.stringify({',
' paneKey,',
' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",',
' tabId: process.env.ORCA_TAB_ID || "",',
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
' env: coords.env,',
+2 -1
View File
@@ -131,6 +131,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
@@ -164,7 +165,7 @@ function getWindowsWrapperScript(eventName: string): string {
function buildWindowsAntigravityHookPostCommand(): string {
// Why: Antigravity hooks are best-effort status updates; do not let a stalled
// local listener hold the agent process open.
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"`
}
function isRecord(value: unknown): value is Record<string, unknown> {
+1
View File
@@ -114,6 +114,7 @@ function getManagedScript(
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -671,6 +671,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
@@ -75,6 +75,7 @@ describe('CommandCodeHookService', () => {
expect(script).toContain('__orca_fill_from_endpoint_file')
expect(script).toContain('[ "$__orca_endpoint_port" != "$ORCA_AGENT_HOOK_PORT" ]')
expect(script).toContain('ORCA_PANE_KEY')
expect(script).toContain('ORCA_AGENT_LAUNCH_TOKEN')
expect(script).toContain('orca-dev/agent-hooks')
expect(script).toContain('endpoint_port=')
}
+3 -1
View File
@@ -105,6 +105,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' ORCA_PANE_KEY) [ -z "${ORCA_PANE_KEY:-}" ] || return 0 ;;',
' ORCA_TAB_ID) [ -z "${ORCA_TAB_ID:-}" ] || return 0 ;;',
' ORCA_WORKTREE_ID) [ -z "${ORCA_WORKTREE_ID:-}" ] || return 0 ;;',
' ORCA_AGENT_LAUNCH_TOKEN) [ -z "${ORCA_AGENT_LAUNCH_TOKEN:-}" ] || return 0 ;;',
' *) return 0 ;;',
' esac',
' __orca_value=$(__orca_read_ancestor_var "$__orca_name") || return 0',
@@ -131,7 +132,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'}',
'# Why: Command Code sanitizes hook subprocess env. The parent TUI process',
'# still has Orca pane/hook metadata, so recover it before posting.',
'for __orca_name in ORCA_AGENT_HOOK_ENDPOINT ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN ORCA_AGENT_HOOK_ENV ORCA_AGENT_HOOK_VERSION ORCA_PANE_KEY ORCA_TAB_ID ORCA_WORKTREE_ID; do',
'for __orca_name in ORCA_AGENT_HOOK_ENDPOINT ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN ORCA_AGENT_HOOK_ENV ORCA_AGENT_HOOK_VERSION ORCA_PANE_KEY ORCA_TAB_ID ORCA_WORKTREE_ID ORCA_AGENT_LAUNCH_TOKEN; do',
' __orca_fill_from_ancestor "$__orca_name"',
'done',
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
@@ -170,6 +171,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+2
View File
@@ -136,6 +136,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' $payload = $inputData | ConvertFrom-Json',
' $body = @{',
' paneKey = $env:ORCA_PANE_KEY',
' launchToken = $env:ORCA_AGENT_LAUNCH_TOKEN',
' tabId = $env:ORCA_TAB_ID',
' worktreeId = $env:ORCA_WORKTREE_ID',
' hookEventName = $env:ORCA_COPILOT_HOOK_EVENT',
@@ -171,6 +172,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
+1
View File
@@ -106,6 +106,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+6 -1
View File
@@ -41,7 +41,12 @@ import { isShellProcess } from '../../shared/shell-process-detection'
import { parsePtySessionId } from './pty-session-id'
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
+1
View File
@@ -86,6 +86,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -93,6 +93,7 @@ function getManagedScript(): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -98,6 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -92,6 +92,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -379,6 +379,7 @@ def _post_to_orca(payload: dict[str, Any]) -> None:
return
body = {
"paneKey": pane_key,
"launchToken": env.get("ORCA_AGENT_LAUNCH_TOKEN", ""),
"tabId": env.get("ORCA_TAB_ID", ""),
"worktreeId": env.get("ORCA_WORKTREE_ID", ""),
"env": env.get("ORCA_AGENT_HOOK_ENV", ""),
+2
View File
@@ -814,6 +814,7 @@ function openMainWindow(): BrowserWindow {
payload,
receivedAt,
stateStartedAt,
launchToken,
providerSession,
isReplay
}) => {
@@ -826,6 +827,7 @@ function openMainWindow(): BrowserWindow {
mainWindow?.webContents.send('agentStatus:set', {
...payload,
paneKey,
...(launchToken ? { launchToken } : {}),
...(terminalHandle ? { terminalHandle } : {}),
tabId,
worktreeId,
+182
View File
@@ -3306,6 +3306,188 @@ describe('registerPtyHandlers', () => {
)
})
it('refreshes captured native Agent Teams env for renderer PTY spawns', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams'),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(async () => ({
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
PATH: `/tmp/fresh-agent-teams${delimiter}/usr/bin`,
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1',
TMUX_PANE: '%1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token'
}
})),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --teammate-mode auto --resume claude-session',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1',
env: {
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
CLAUDE_PROFILE: 'captured',
PATH: `/tmp/stale-agent-teams${delimiter}/usr/bin`,
TMUX: '/tmp/orca-claude-agent-teams/team-stale,0,1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
launchConfig: {
agentCommand: 'claude --teammate-mode auto',
agentArgs: '',
agentEnv: {
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token'
}
},
launchAgent: 'claude'
})) as { launchConfig?: { agentEnv: Record<string, string> } }
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith({
handle: 'term_agent_teams',
baseEnv: expect.objectContaining({
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale'
})
})
expect(spawnOptions.env).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_TERMINAL_HANDLE: 'term_agent_teams',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token',
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1',
TMUX_PANE: '%1'
})
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/fresh-agent-teams')
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(result.launchConfig?.agentEnv).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token',
TMUX: '/tmp/orca-claude-agent-teams/team-fresh,0,1'
})
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
expect.any(String),
'term_agent_teams'
)
})
it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams'),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(async () => ({
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh',
ORCA_AGENT_TEAMS_TOKEN: 'fresh-token'
}
})),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --resume claude-session',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1',
env: {
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
launchConfig: {
agentCommand: 'claude',
agentArgs: '--teammate-mode auto',
agentEnv: {}
},
launchAgent: 'claude'
})
expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith({
handle: 'term_agent_teams',
baseEnv: expect.any(Object)
})
})
it('does not echo launch config for provider reattach results', async () => {
const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true }))
registerSshPtyProvider('ssh-reattach-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-1',
launchConfig: {
agentCommand: 'codex --model gpt-5',
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
})) as { id: string; isReattach?: boolean; launchConfig?: unknown }
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
spawn(args: {
+69 -9
View File
@@ -16,7 +16,8 @@ import {
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/types'
import type { GlobalSettings, TuiAgent } from '../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import {
isWslShellName,
@@ -182,6 +183,16 @@ function isValidPaneKey(paneKey: unknown): paneKey is string {
return parseValidPaneKey(paneKey) !== null
}
function shouldRefreshNativeClaudeAgentTeamsEnv(args: {
command?: string
launchConfig?: SleepingAgentLaunchConfig
}): boolean {
const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || ''
const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? ''
const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim()
return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch)
}
function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null {
const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : ''
if (!isValidPaneKey(normalizedPaneKey)) {
@@ -269,7 +280,10 @@ function stripRemotePaneEnvWhenHooksDisabled(
}
if (
!env ||
(!('ORCA_PANE_KEY' in env) && !('ORCA_TAB_ID' in env) && !('ORCA_WORKTREE_ID' in env))
(!('ORCA_PANE_KEY' in env) &&
!('ORCA_TAB_ID' in env) &&
!('ORCA_WORKTREE_ID' in env) &&
!('ORCA_AGENT_LAUNCH_TOKEN' in env))
) {
return env
}
@@ -277,6 +291,7 @@ function stripRemotePaneEnvWhenHooksDisabled(
delete stripped.ORCA_PANE_KEY
delete stripped.ORCA_TAB_ID
delete stripped.ORCA_WORKTREE_ID
delete stripped.ORCA_AGENT_LAUNCH_TOKEN
return stripped
}
@@ -2113,6 +2128,8 @@ export function registerPtyHandlers(
env?: Record<string, string>
envToDelete?: string[]
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -2245,8 +2262,46 @@ export function registerPtyHandlers(
? makePaneKey(args.tabId, args.leafId)
: null
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
const shouldRefreshAgentTeamsEnv =
!args.connectionId &&
runtime !== undefined &&
stablePaneKey !== null &&
shouldRefreshNativeClaudeAgentTeamsEnv({
command: args.command,
launchConfig: args.launchConfig
})
let effectiveLaunchConfig = args.launchConfig
const preAllocatedHandle =
runtime && (!(provider instanceof LocalPtyProvider) || shouldRefreshAgentTeamsEnv)
? runtime.createPreAllocatedTerminalHandle()
: null
if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) {
// Why: native Agent Teams team ids/tokens are process-local. A sleeping
// record preserves the user's native launch shape, but the team env
// itself must be regenerated for the new leader PTY.
const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({
handle: preAllocatedHandle,
baseEnv: baseEnv ?? {}
})
baseEnv = {
...baseEnv,
...prepared.env
}
if (args.launchConfig) {
effectiveLaunchConfig = {
...args.launchConfig,
agentEnv: {
...args.launchConfig.agentEnv,
...prepared.env
}
}
}
}
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv
? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
: undefined
if (baseEnv && stablePaneKey) {
baseEnv.ORCA_PANE_KEY = stablePaneKey
if (typeof args.tabId === 'string') {
@@ -2265,14 +2320,11 @@ export function registerPtyHandlers(
delete baseEnv.ORCA_PANE_KEY
delete baseEnv.ORCA_TAB_ID
delete baseEnv.ORCA_WORKTREE_ID
delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN
}
const validatedPaneKey = stablePaneKey
const validatedLeafId = verifiedLeafId ?? metadataLeafId
let env: Record<string, string> | undefined = baseEnv
const preAllocatedHandle =
runtime && !(provider instanceof LocalPtyProvider)
? runtime.createPreAllocatedTerminalHandle()
: null
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
const codexSelectionTarget = getCodexSelectionTargetForPty(
effectiveShellOverride,
@@ -2344,7 +2396,10 @@ export function registerPtyHandlers(
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(
mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []),
mergePtyEnvDeletions(
mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []),
agentTeamsEnvToDelete ?? []
),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : []
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
@@ -2663,7 +2718,12 @@ export function registerPtyHandlers(
})
}
}
return result
return {
...result,
...(!result.isReattach && effectiveLaunchConfig
? { launchConfig: effectiveLaunchConfig }
: {})
}
}
)
+2
View File
@@ -250,6 +250,8 @@ async function spawnLocalStartupAndSetupTerminals(args: {
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
command: startup.command,
env: startup.env,
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
startupCommandDelivery: startup.startupCommandDelivery,
telemetry: startup.telemetry,
activate: true
+2
View File
@@ -938,6 +938,8 @@ describe('registerWorktreeHandlers', () => {
{
command: 'claude --prefill test',
env: { ORCA_AGENT_MODE: 'direct' },
launchAgent: 'claude',
startupCommandDelivery: undefined,
telemetry: {
agent_kind: 'claude',
launch_source: 'new_workspace_composer',
+1
View File
@@ -81,6 +81,7 @@ function getManagedScript(): string {
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+1
View File
@@ -263,6 +263,7 @@ function getOpenCodePluginSource(): string {
' const url = `http://127.0.0.1:${coords.port}/hook/opencode`;',
' const body = JSON.stringify({',
' paneKey,',
' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",',
' tabId: process.env.ORCA_TAB_ID || "",',
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
' env: coords.env,',
@@ -111,6 +111,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
' const url = `http://127.0.0.1:${coords.port}${resolveHookPath()}`',
' const body = JSON.stringify({',
' paneKey,',
" launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || '',",
" tabId: process.env.ORCA_TAB_ID || '',",
" worktreeId: process.env.ORCA_WORKTREE_ID || '',",
' env: coords.env,',
+6 -1
View File
@@ -44,7 +44,12 @@ import { getAgentForegroundContextPaths } from './agent-foreground-context-paths
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
+288 -1
View File
@@ -6205,6 +6205,10 @@ describe('OrcaRuntimeService', () => {
const result = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
title: 'worker'
})
@@ -6232,9 +6236,15 @@ describe('OrcaRuntimeService', () => {
expectStablePaneKeyEnv(spawnedEnv)
const spawnedLeafId = spawnedEnv.ORCA_PANE_KEY.slice(`${spawnedEnv.ORCA_TAB_ID}:`.length)
expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_WORKTREE_ID)
expect(spawnedEnv.ORCA_AGENT_LAUNCH_TOKEN).toMatch(UUID_RE)
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, {
ptyId: 'pty-bg',
title: 'worker',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchToken: spawnedEnv.ORCA_AGENT_LAUNCH_TOKEN,
activate: false,
tabId: spawnedEnv.ORCA_TAB_ID,
leafId: spawnedLeafId
@@ -6374,6 +6384,222 @@ describe('OrcaRuntimeService', () => {
expect(normalAgent.env?.TMUX).toBeUndefined()
})
it('reveals Claude Agent Teams launches with the rewritten launch config', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: "claude 'hello'",
launchAgent: 'claude',
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
agentEnv: { CLAUDE_PROFILE: 'captured' }
}
})
const spawnCall = spawn.mock.calls[0]?.[0] as { env?: Record<string, string> } | undefined
const spawnedEnv = spawnCall?.env ?? {}
const spawnedLeafId = spawnedEnv.ORCA_PANE_KEY.slice(`${spawnedEnv.ORCA_TAB_ID}:`.length)
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, {
ptyId: 'pty-bg',
title: null,
launchConfig: {
agentCommand: 'claude --teammate-mode in-process',
agentArgs: '',
agentEnv: {
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1'
}
},
launchToken: spawnedEnv.ORCA_AGENT_LAUNCH_TOKEN,
launchAgent: 'claude',
activate: false,
tabId: spawnedEnv.ORCA_TAB_ID,
leafId: spawnedLeafId
})
})
it('restores captured native Claude Agent Teams mode with fresh service env', async () => {
setPlatform('linux')
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'off' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'claude --resume claude-session',
env: {
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1'
},
launchAgent: 'claude',
launchConfig: {
agentCommand: 'claude',
agentArgs: '--teammate-mode auto',
agentEnv: {
CLAUDE_PROFILE: 'captured',
ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1'
}
}
})
const spawnCall = spawn.mock.calls[0]?.[0] as
| { command?: string; env?: Record<string, string> }
| undefined
expect(spawnCall?.command).toBe('claude --teammate-mode auto --resume claude-session')
expect(spawnCall?.env).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).toMatch(/^team-/)
expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).not.toBe('stale-team')
expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TOKEN).not.toBe('stale-token')
expect(spawnCall?.env?.TMUX).not.toBe('/tmp/orca-claude-agent-teams/stale-team,0,1')
expect(revealTerminalSession).toHaveBeenCalledWith(
TEST_WORKTREE_ID,
expect.objectContaining({
launchConfig: expect.objectContaining({
agentCommand: 'claude --teammate-mode auto',
agentEnv: expect.objectContaining({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
}),
launchAgent: 'claude'
})
)
const revealedLaunchConfig = revealTerminalSession.mock.calls[0]?.[1]?.launchConfig
expect(revealedLaunchConfig?.agentEnv.ORCA_AGENT_TEAMS_TEAM_ID).not.toBe('stale-team')
expect(revealedLaunchConfig?.agentEnv.ORCA_AGENT_TEAMS_TOKEN).not.toBe('stale-token')
})
it('does not apply current Agent Teams mode to captured plain Claude resumes', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'claude --resume claude-session',
launchAgent: 'claude',
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
agentEnv: { CLAUDE_PROFILE: 'captured' }
}
})
const spawnCall = spawn.mock.calls[0]?.[0] as
| { command?: string; env?: Record<string, string> }
| undefined
expect(spawnCall?.command).toBe('claude --resume claude-session')
expect(spawnCall?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(revealTerminalSession).toHaveBeenCalledWith(
TEST_WORKTREE_ID,
expect.objectContaining({
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
agentEnv: { CLAUDE_PROFILE: 'captured' }
},
launchAgent: 'claude'
})
)
})
it('adopts renderer pane identity for remote runtime terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
@@ -7670,6 +7896,63 @@ describe('OrcaRuntimeService', () => {
})
})
it('replays captured launch config when focusing a background agent session', async () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
launchAgent: 'codex',
launchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
title: 'worker'
})
const firstReveal = revealTerminalSession.mock.calls[0]?.[1] as
| { launchToken?: string; tabId?: string; leafId?: string }
| undefined
revealTerminalSession.mockClear()
await runtime.focusTerminal(handle)
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, {
ptyId: 'pty-bg',
title: 'worker',
launchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchToken: firstReveal?.launchToken,
launchAgent: 'codex',
tabId: firstReveal?.tabId,
leafId: firstReveal?.leafId
})
})
it('reveals background terminal sessions with the freshest PTY title', async () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' })
const runtime = new OrcaRuntimeService(store)
@@ -13315,7 +13598,8 @@ describe('OrcaRuntimeService', () => {
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
agentCmdOverrides: { 'command-code': 'command-code --profile mobile' }
agentCmdOverrides: { 'command-code': 'command-code --profile mobile' },
agentDefaultEnv: { 'command-code': { COMMAND_CODE_PROFILE: 'mobile-env' } }
})
} as never)
runtime.setPtyController({
@@ -13334,6 +13618,9 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
command: "command-code --profile mobile '--yolo'",
cwd: TEST_WORKTREE_PATH,
env: expect.objectContaining({
COMMAND_CODE_PROFILE: 'mobile-env'
}),
worktreeId: TEST_WORKTREE_ID
})
)
+171 -16
View File
@@ -111,6 +111,7 @@ import type {
WorkspaceSessionState,
DirEntry
} from '../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type {
@@ -296,6 +297,11 @@ import {
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
import {
addClaudeTeammateModeAuto,
addClaudeTeammateModeInProcess,
type ClaudeAgentTeamsMode
} from '../../shared/claude-agent-teams-tmux-compat'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { BrowserWindow, ipcMain } from 'electron'
@@ -877,6 +883,9 @@ type RuntimePtyWorktreeRecord = {
// spawn-time tab/pane identity so later reveals can adopt under the env key.
tabId: string | null
paneKey: string | null
launchConfig: SleepingAgentLaunchConfig | null
launchToken: string | null
launchAgent: TuiAgent | null
connected: boolean
disconnectedAt: number | null
lastExitCode: number | null
@@ -898,6 +907,36 @@ type RuntimePtyWorktreeRecord = {
waitBlockedAt: number | null
}
function copySleepingAgentLaunchConfig(
config: SleepingAgentLaunchConfig
): SleepingAgentLaunchConfig {
return {
...(config.agentCommand ? { agentCommand: config.agentCommand } : {}),
agentArgs: config.agentArgs,
agentEnv: { ...config.agentEnv }
}
}
function inferCapturedClaudeAgentTeamsMode(
launchConfig: SleepingAgentLaunchConfig | undefined,
command: string | undefined,
currentMode: ClaudeAgentTeamsMode | undefined
): ClaudeAgentTeamsMode | undefined {
const capturedCommand = launchConfig?.agentCommand?.trim() || command?.trim() || ''
const capturedArgs = launchConfig?.agentArgs?.trim() ?? ''
const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim()
if (/(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch)) {
return 'native-panes-shim'
}
if (/(^|\s)--teammate-mode(?:=|\s+)in-process(?:\s|$)/.test(capturedLaunch)) {
return 'in-process'
}
if (launchConfig && /(^|\s)--resume(?:\s|=|$)/.test(command?.trim() ?? '')) {
return 'off'
}
return currentMode
}
export type RuntimeTerminalAgentStatusEvent = {
ptyId: string
source: 'mounted-leaf' | 'pty-record'
@@ -1021,12 +1060,18 @@ type RuntimeNotifier = {
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs']
): void
createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void
createTerminal(
worktreeId: string,
opts: { command?: string; env?: Record<string, string>; title?: string }
): void
revealTerminalSession?(
worktreeId: string,
opts: {
ptyId: string
title?: string | null
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
activate?: boolean
tabId?: string
leafId?: string
@@ -3599,6 +3644,7 @@ export class OrcaRuntimeService {
undefined,
undefined,
undefined,
undefined,
{
tabId: tab.parentTabId,
leafId: tab.leafId,
@@ -11409,6 +11455,7 @@ export class OrcaRuntimeService {
agent,
startup: {
command: draftLaunchPlan.launchCommand,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
@@ -11433,6 +11480,7 @@ export class OrcaRuntimeService {
agent,
startup: {
command: startupPlan.launchCommand,
launchConfig: startupPlan.launchConfig,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -11473,6 +11521,7 @@ export class OrcaRuntimeService {
agent,
startup: {
command: startupPlan.launchCommand,
launchConfig: startupPlan.launchConfig,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -11897,6 +11946,10 @@ export class OrcaRuntimeService {
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: effectiveStartup.command,
env: effectiveStartup.env,
...(effectiveStartup.launchConfig
? { launchConfig: effectiveStartup.launchConfig }
: {}),
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
telemetry: effectiveStartup.telemetry
})
@@ -12480,6 +12533,8 @@ export class OrcaRuntimeService {
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: effectiveStartup.command,
env: effectiveStartup.env,
...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}),
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
telemetry: effectiveStartup.telemetry
})
@@ -12739,6 +12794,8 @@ export class OrcaRuntimeService {
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
command: args.startup.command,
env: args.startup.env,
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
startupCommandDelivery: args.startup.startupCommandDelivery,
telemetry: args.startup.telemetry
})
@@ -14299,6 +14356,9 @@ export class OrcaRuntimeService {
opts: {
command?: string
env?: Record<string, string>
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
telemetry?: WorktreeStartupLaunch['telemetry']
title?: string
@@ -14351,10 +14411,20 @@ export class OrcaRuntimeService {
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
const paneKey = makePaneKey(tabId, leafId)
const baseEnv = opts.env ?? {}
const launchToken = opts.launchConfig ? (opts.launchToken ?? randomUUID()) : undefined
const baseEnv = {
...opts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
opts.launchConfig,
opts.command,
claudeAgentTeamsMode
)
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: opts.command,
mode: this.store?.getSettings?.().claudeAgentTeamsMode,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
@@ -14370,6 +14440,21 @@ export class OrcaRuntimeService {
shimBin
}).env
})
const effectiveLaunchConfig =
opts.launchConfig && agentTeamsPlan
? {
...opts.launchConfig,
agentCommand: opts.launchConfig.agentCommand
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(opts.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(opts.launchConfig.agentCommand)
: agentTeamsPlan.command,
agentEnv: {
...opts.launchConfig.agentEnv,
...agentTeamsPlan.env
}
}
: opts.launchConfig
const env = this.buildTerminalWorkspaceEnv(
workspace,
baseEnv,
@@ -14409,6 +14494,11 @@ export class OrcaRuntimeService {
}
pty.tabId = tabId
pty.paneKey = paneKey
pty.launchConfig = effectiveLaunchConfig
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchAgent = opts.launchAgent ?? null
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && opts.deferMobileSessionPublish !== true) {
@@ -14429,6 +14519,9 @@ export class OrcaRuntimeService {
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: opts.title ?? null,
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
activate: opts.activate === true,
tabId,
leafId
@@ -14479,6 +14572,10 @@ export class OrcaRuntimeService {
requestId,
worktreeId,
command: opts.command,
...(opts.env ? { env: opts.env } : {}),
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
title: opts.title,
activate: opts.focus === true || opts.activate === true
@@ -14516,6 +14613,8 @@ export class OrcaRuntimeService {
return await this.createTerminal(`id:${worktree.id}`, {
command: startup.startup.command,
env: startup.startup.env,
...(startup.startup.launchConfig ? { launchConfig: startup.startup.launchConfig } : {}),
launchAgent: startup.agent,
startupCommandDelivery: startup.startup.startupCommandDelivery,
telemetry: startup.startup.telemetry,
title: opts.title
@@ -14528,8 +14627,11 @@ export class OrcaRuntimeService {
afterTabId?: string
targetGroupId?: string
command?: string
env?: Record<string, string>
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
agent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
activate?: boolean
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
@@ -14546,7 +14648,7 @@ export class OrcaRuntimeService {
}
afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id
}
const command = await this.resolveMobileSessionTerminalCommand(workspace, opts)
const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts)
const win = this.getAvailableAuthoritativeWindow()
if (!win) {
@@ -14554,11 +14656,13 @@ export class OrcaRuntimeService {
worktreeId,
opts.activate !== false,
opts.afterTabId,
command,
opts.startupCommandDelivery,
startupCommand.command,
startupCommand.env,
startupCommand.startupCommandDelivery,
undefined,
opts.agent,
opts.targetGroupId
startupCommand.launchAgent,
opts.targetGroupId,
startupCommand.launchConfig
)
}
const requestId = randomUUID()
@@ -14589,8 +14693,11 @@ export class OrcaRuntimeService {
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command,
startupCommandDelivery: opts.startupCommandDelivery,
command: startupCommand.command,
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
activate: opts.activate
})
})
@@ -14603,10 +14710,29 @@ export class OrcaRuntimeService {
private async resolveMobileSessionTerminalCommand(
workspace: TerminalWorkspaceLaunchScope,
opts: { command?: string; agent?: TuiAgent }
): Promise<string | undefined> {
opts: {
command?: string
env?: Record<string, string>
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
agent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
}
): Promise<{
command?: string
env?: Record<string, string>
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
}> {
if (opts.command || !opts.agent) {
return opts.command
return {
command: opts.command,
env: opts.env,
launchConfig: opts.launchConfig,
launchAgent: opts.launchAgent,
startupCommandDelivery: opts.startupCommandDelivery
}
}
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -14639,7 +14765,13 @@ export class OrcaRuntimeService {
} else {
this.markLocalWorkspaceTrustedForAgent(opts.agent, workspace.path)
}
return startupPlan.launchCommand
return {
command: startupPlan.launchCommand,
env: startupPlan.env,
launchConfig: startupPlan.launchConfig,
launchAgent: opts.agent,
startupCommandDelivery: startupPlan.startupCommandDelivery
}
}
private async createHeadlessMobileSessionTerminal(
@@ -14647,10 +14779,12 @@ export class OrcaRuntimeService {
activate: boolean,
afterTabId?: string,
command?: string,
env?: Record<string, string>,
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'],
identity?: { tabId: string; leafId: string; sessionId?: string },
launchAgent?: TuiAgent,
targetGroupId?: string
targetGroupId?: string,
launchConfig?: SleepingAgentLaunchConfig
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
// Why: SshPtyProvider treats sessionId as a relay reattach request. Only
@@ -14660,6 +14794,9 @@ export class OrcaRuntimeService {
const terminal = await this.createTerminal(`id:${worktreeId}`, {
focus: false,
command,
env,
...(launchConfig ? { launchConfig } : {}),
...(launchAgent ? { launchAgent } : {}),
startupCommandDelivery,
...(identity
? {
@@ -14952,6 +15089,11 @@ export class OrcaRuntimeService {
const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, {
ptyId: pty.pty.ptyId,
title: getLatestPtyTitle(pty.pty),
...(pty.pty.launchConfig
? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) }
: {}),
...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}),
...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}),
...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}),
...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {})
})
@@ -15136,6 +15278,16 @@ export class OrcaRuntimeService {
if (!handle) {
throw new Error('claude_agent_teams_requires_orca_terminal')
}
return await this.prepareClaudeAgentTeamsLeaderForHandle({
handle,
baseEnv: args.baseEnv
})
}
async prepareClaudeAgentTeamsLeaderForHandle(args: {
handle: string
baseEnv?: Record<string, string>
}): Promise<{ env: Record<string, string> }> {
const baseEnv = {
...process.env,
...args.baseEnv
@@ -15143,7 +15295,7 @@ export class OrcaRuntimeService {
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv)
return this.claudeAgentTeams.createLaunchEnv({
leaderHandle: handle,
leaderHandle: args.handle,
baseEnv,
shimDir,
shimBin
@@ -16369,6 +16521,9 @@ export class OrcaRuntimeService {
connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null,
tabId: state.tabId ?? null,
paneKey: state.paneKey ?? null,
launchConfig: null,
launchToken: null,
launchAgent: null,
connected: state.connected ?? true,
disconnectedAt: state.connected === false ? Date.now() : null,
lastExitCode: null,
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../shared/types'
import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
export const WorktreeTabSelector = z.object({
worktree: z
@@ -108,12 +109,22 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({
afterTabId: z.string().optional(),
targetGroupId: z.string().optional(),
command: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
launchConfig: sleepingAgentLaunchConfigSchema,
launchToken: z.string().min(1).max(128).optional(),
agent: z
.custom<TuiAgent>(isTuiAgent, {
message: 'Unknown agent preset'
})
.optional(),
// Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan
// identity used when preserving resume config across runtime boundaries.
launchAgent: z
.custom<TuiAgent>(isTuiAgent, {
message: 'Unknown launch agent'
})
.optional(),
activate: z.boolean().optional()
})
@@ -110,6 +110,13 @@ describe('session tab RPC methods', () => {
worktree: 'id:wt-1',
targetGroupId: 'group-left',
command: 'zsh',
env: { CODEX_PROFILE: 'captured' },
launchToken: 'launch-token-123',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
activate: true
})
)
@@ -119,8 +126,15 @@ describe('session tab RPC methods', () => {
afterTabId: undefined,
targetGroupId: 'group-left',
command: 'zsh',
env: { CODEX_PROFILE: 'captured' },
startupCommandDelivery: undefined,
agent: undefined,
launchToken: 'launch-token-123',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
activate: true
})
})
@@ -44,8 +44,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
afterTabId: params.afterTabId,
targetGroupId: params.targetGroupId,
command: params.command,
...(params.env ? { env: params.env } : {}),
startupCommandDelivery: params.startupCommandDelivery,
agent: params.agent,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
activate: params.activate
})
}),
+13
View File
@@ -25,6 +25,7 @@ import {
isTerminalInputTooLargeWithYield
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
// Why: when a mobile client subscribes the server resizes the PTY to phone
// dims and serializes the buffer. Sending only the visible screen meant
@@ -614,6 +615,15 @@ const TerminalCreateParams = z.object({
command: OptionalString,
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
env: z.record(z.string(), z.string()).optional(),
launchConfig: z
.object({
agentCommand: z.string().optional(),
agentArgs: z.string(),
agentEnv: z.record(z.string(), z.string())
})
.optional(),
launchToken: OptionalString,
launchAgent: z.string().refine(isTuiAgent).optional(),
title: OptionalString,
focus: z.unknown().optional(),
rendererBacked: z.unknown().optional(),
@@ -984,6 +994,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../shared/types'
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
import {
OptionalBoolean,
OptionalFiniteNumber,
@@ -125,6 +126,7 @@ export const WorktreeCreate = z
// terminal pane launches the selected agent instead of an idle shell.
startupCommand: OptionalString,
startupEnv: z.record(z.string(), z.string()).optional(),
startupLaunchConfig: sleepingAgentLaunchConfigSchema,
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
// Why: CLI clients should not hardcode agent launch quoting because SSH
// workspaces execute in a different shell than the client process.
+45 -1
View File
@@ -417,6 +417,11 @@ describe('worktree RPC methods', () => {
startupCommand: "codex 'summarize repo'",
startupCommandDelivery: 'shell-ready',
startupEnv: { ORCA_AGENT_MODE: 'direct' },
startupLaunchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
},
activate: true
})
)
@@ -429,12 +434,51 @@ describe('worktree RPC methods', () => {
startup: {
command: "codex 'summarize repo'",
startupCommandDelivery: 'shell-ready',
env: { ORCA_AGENT_MODE: 'direct' }
env: { ORCA_AGENT_MODE: 'direct' },
launchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
}
}
})
)
})
it('drops invalid startup launch config env at the runtime RPC boundary', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'agent-startup',
startupCommand: "codex 'summarize repo'",
startupLaunchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ['__proto__']: 'polluted' }
}
})
)
expect(response.ok).toBe(true)
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
startup: expect.objectContaining({
command: "codex 'summarize repo'"
})
})
)
expect(vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0].startup).not.toHaveProperty(
'launchConfig'
)
})
it('forwards task startup drafts to runtime worktree creation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+1
View File
@@ -106,6 +106,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
? {
command: params.startupCommand,
...(params.startupEnv ? { env: params.startupEnv } : {}),
...(params.startupLaunchConfig ? { launchConfig: params.startupLaunchConfig } : {}),
...(params.startupCommandDelivery
? { startupCommandDelivery: params.startupCommandDelivery }
: {})
+2
View File
@@ -751,6 +751,7 @@ export class SshRelaySession {
}
const envelope = params as {
paneKey?: unknown
launchToken?: unknown
tabId?: unknown
worktreeId?: unknown
env?: unknown
@@ -776,6 +777,7 @@ export class SshRelaySession {
agentHookServer.ingestRemote(
{
paneKey: envelope.paneKey,
launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined,
tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined,
worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined,
env: typeof envelope.env === 'string' ? envelope.env : undefined,
@@ -237,7 +237,12 @@ function registerRuntimeWindowLifecycle(
})
},
createTerminal: (worktreeId, opts) =>
send('ui:createTerminal', { worktreeId, command: opts.command, title: opts.title }),
send('ui:createTerminal', {
worktreeId,
command: opts.command,
...(opts.env ? { env: opts.env } : {}),
title: opts.title
}),
revealTerminalSession: (worktreeId, opts) =>
new Promise((resolve, reject) => {
const requestId = randomUUID()
@@ -266,6 +271,9 @@ function registerRuntimeWindowLifecycle(
worktreeId,
ptyId: opts.ptyId,
title: opts.title ?? undefined,
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
activate: opts.activate !== false,
// Why: pre-minted tabId from main keeps the renderer's tab id aligned
// with the paneKey baked into the PTY env at spawn time, so hook
+13
View File
@@ -15,6 +15,7 @@ import type { TerminalPaneSplitSource } from '../shared/feature-education-teleme
import type { TaskSourceContext } from '../shared/task-source-context'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
@@ -1013,6 +1014,9 @@ export type PreloadApi = {
cwd?: string
env?: Record<string, string>
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -1034,6 +1038,7 @@ export type PreloadApi = {
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
}) => Promise<{
id: string
launchConfig?: SleepingAgentLaunchConfig
snapshot?: string
snapshotCols?: number
snapshotRows?: number
@@ -2372,6 +2377,10 @@ export type PreloadApi = {
requestId?: string
worktreeId: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
title?: string
ptyId?: string
activate?: boolean
@@ -2389,6 +2398,10 @@ export type PreloadApi = {
afterTabId?: string
targetGroupId?: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
+22
View File
@@ -11,6 +11,7 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
import type {
BaseRefSearchResult,
BaseRefDefaultResult,
@@ -44,6 +45,7 @@ import type {
FloatingTerminalCwdRequest,
MarkdownDocument,
SearchResult,
TuiAgent,
UpdateStatus,
WorktreeBaseStatusEvent,
WorktreeDefaultTabsLaunch,
@@ -700,6 +702,9 @@ const api = {
cwd?: string
env?: Record<string, string>
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -720,6 +725,7 @@ const api = {
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
}): Promise<{
id: string
launchConfig?: SleepingAgentLaunchConfig
snapshot?: string
snapshotCols?: number
snapshotRows?: number
@@ -3004,6 +3010,10 @@ const api = {
requestId?: string
worktreeId: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
title?: string
ptyId?: string
activate?: boolean
@@ -3020,6 +3030,10 @@ const api = {
requestId?: string
worktreeId: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
title?: string
ptyId?: string
activate?: boolean
@@ -3040,6 +3054,10 @@ const api = {
afterTabId?: string
targetGroupId?: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
@@ -3053,6 +3071,10 @@ const api = {
afterTabId?: string
targetGroupId?: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
+1
View File
@@ -305,6 +305,7 @@ export class RelayAgentHookServer {
const envelope: AgentHookRelayEnvelope = {
source,
paneKey: event.paneKey,
...(event.launchToken ? { launchToken: event.launchToken } : {}),
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
@@ -78,6 +78,8 @@ export function FloatingTerminalWindowControls({
state.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: defaultAgent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -16,6 +16,12 @@ describe('buildOnboardingFolderAgentStartup', () => {
expect(startup).toEqual({
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
telemetry: {
agent_kind: 'codex',
launch_source: 'onboarding',
@@ -93,6 +99,12 @@ describe('buildOnboardingFolderAgentStartup', () => {
).toEqual({
command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
telemetry: {
agent_kind: 'codex',
launch_source: 'onboarding',
@@ -57,6 +57,7 @@ describe('ExperimentalPane', () => {
expect(settings.experimentalAgentHibernation).toBe(false)
expect(settings.agentHibernationIdleMs).toBe(30 * 60 * 1000)
expect(markup).toContain('Agent sleep')
expect(markup).toContain('Manually started agents may resume')
expect(markup).not.toContain('Sleep after')
expect(markup).toContain('aria-checked="false"')
expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain('Agent sleep')
@@ -222,7 +222,7 @@ export function ExperimentalPane({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentHibernation.copy',
'Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again. Experimental while we tune the safety model.'
'Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again. Agent sleep preserves launch options for agents started by Orca. Manually started agents may resume with your current Orca defaults. Experimental while we tune the safety model.'
)}
</p>
</div>
@@ -152,7 +152,7 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
),
description: translate(
'auto.components.settings.experimental.search.agentHibernation.description',
'Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again.'
'Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again. Agent sleep preserves launch options for agents started by Orca; manually started agents may resume with current Orca defaults.'
),
keywords: [
...translateSearchKeyword(
@@ -95,6 +95,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
@@ -228,6 +229,8 @@ export async function submitFolderWorkspaceCreate({
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: quickAgent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -5,6 +5,7 @@ import type { EventProps } from '../../../../shared/telemetry-events'
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
export type PtyConnectionDeps = {
tabId: string
@@ -17,6 +18,9 @@ export type PtyConnectionDeps = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
/** Telemetry payload for `agent_started`. Forwarded to `pty:spawn`
* so main fires the event only after the spawn succeeds. */
telemetry?: EventProps<'agent_started'>
@@ -40,6 +40,7 @@ async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100
const toastInfo = vi.fn()
const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1_500
@@ -95,6 +96,8 @@ type StoreState = {
customSoundPath?: string | null
}
agentCmdOverrides?: Record<string, string>
agentDefaultArgs?: Record<string, string>
agentDefaultEnv?: Record<string, Record<string, string>>
} | null
codexRestartNoticeByPtyId: Record<
string,
@@ -109,7 +112,11 @@ type StoreState = {
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
sleepingAgentSessionsByPaneKey: Record<string, unknown>
agentLaunchConfigByPaneKey: Record<string, { launchConfig: unknown }>
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
registerAgentLaunchConfig: ReturnType<typeof vi.fn>
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
recordTerminalInput: ReturnType<typeof vi.fn>
@@ -508,9 +515,15 @@ describe('connectPanePty', () => {
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
agentLaunchConfigByPaneKey: {},
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig
}),
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
}),
registerAgentLaunchConfig: vi.fn(),
clearAgentLaunchConfig: vi.fn(),
markWorktreeUnread: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
recordTerminalInput: vi.fn(),
@@ -2868,6 +2881,47 @@ describe('connectPanePty', () => {
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
it('clears pre-hook launch config when an Orca-started command exits', async () => {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-local-1'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({
startup: {
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
launchConfig: {
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
launchAgent: 'codex'
}
}) as never
)
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(
paneKey,
{
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
expect.objectContaining({ agentType: 'codex' })
)
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey)
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
})
it('flushes pending interrupt inference before dropping an exited foreground agent command', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -3223,6 +3277,93 @@ describe('connectPanePty', () => {
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-ssh-pty')
})
it('submits a cold-restore resume command after SSH expired-session fallback', async () => {
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = vi.fn((fn: () => void) => {
pendingTimeouts.push(fn)
return 999 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout
try {
const { connectPanePty } = await import('./pty-connection')
const paneKey = makePaneKey('tab-1', LEAF_2)
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async (opts: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (opts.sessionId) {
opts.callbacks?.onError?.('SSH_SESSION_EXPIRED: restored-session')
return undefined
}
capturedDataCallback.current = opts.callbacks?.onData ?? null
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.('fresh-ssh-pty')
return 'fresh-ssh-pty'
}
)
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'restored-session' }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]),
settings: {
...mockStoreState.settings,
agentCmdOverrides: {}
},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
const pane = createPane(2)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
capturedDataCallback.current?.('user@remote $ ')
for (const fn of pendingTimeouts) {
fn()
}
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(transport.connect).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
it('clears the pending serializer when disposed before non-deferred SSH reattach expiry resolves', async () => {
const { connectPanePty } = await import('./pty-connection')
const reattach = createDeferred<undefined>()
@@ -3487,8 +3628,19 @@ describe('connectPanePty', () => {
expect.stringContaining('--- session restored ---'),
expect.any(Function)
)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
})
@@ -3558,8 +3710,20 @@ describe('connectPanePty', () => {
await new Promise((resolve) => setTimeout(resolve, 70))
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\\''s'\r"
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command:
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\\''s'",
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
})
@@ -3624,14 +3788,298 @@ describe('connectPanePty', () => {
expect(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBe(-1)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
// Why: consuming the record prevents a later worktree activation from
// launching a duplicate resume tab for the same session.
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('uses sleeping-record launch config for pane cold restore after settings change', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'",
agentArgs: '--model gpt-5 --reasoning-effort high',
agentEnv: {
CODEX_PROFILE: 'captured',
ORCA_PANE_KEY: 'wrong-pane',
ORCA_TAB_ID: 'wrong-tab',
ORCA_WORKTREE_ID: 'wrong-worktree',
ORCA_WORKSPACE_ID: 'wrong-workspace'
}
}
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
},
settings: {
...mockStoreState.settings,
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--model changed' },
agentDefaultEnv: { codex: { CODEX_PROFILE: 'changed' } }
},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
launchConfig
}
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'resume' 'codex-session-1'",
env: expect.objectContaining({
CODEX_PROFILE: 'captured',
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, {
agentType: 'codex',
launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)),
tabId: 'tab-1',
leafId: LEAF_1
})
})
it('clears stale launch config when a pane consumes a non-agent startup command', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ startup: { command: 'echo plain-command' } }) as never
)
await flushAsyncTicks()
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey)
})
it('prefers live-entry launch config for pane cold restore when status survived PTY loss', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5-mini'",
agentArgs: '--model gpt-5-mini',
agentEnv: {}
}
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
},
settings: {
...mockStoreState.settings,
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--model changed' }
},
agentStatusByPaneKey: {
[paneKey]: {
paneKey,
state: 'working',
prompt: 'finish the task',
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' }
}
},
agentLaunchConfigByPaneKey: {
[paneKey]: { launchConfig }
},
sleepingAgentSessionsByPaneKey: {}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--model' 'gpt-5-mini' 'resume' 'codex-session-1'",
env: expect.objectContaining({
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, {
agentType: 'codex',
launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)),
tabId: 'tab-1',
leafId: LEAF_1
})
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
})
it('ignores stale live launch config when cold restore identity lookup rejects it', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
},
settings: {
...mockStoreState.settings,
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--model current' }
},
agentStatusByPaneKey: {
[paneKey]: {
paneKey,
state: 'working',
prompt: 'finish the task',
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' }
}
},
agentLaunchConfigByPaneKey: {
[paneKey]: {
launchConfig: {
agentCommand: "codex '--model' 'stale'",
agentArgs: '--model stale',
agentEnv: {}
}
}
},
getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined),
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'older-codex-session' },
prompt: 'older task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
launchConfig: {
agentCommand: "codex '--model' 'sleeping-stale'",
agentArgs: '--model sleeping-stale',
agentEnv: { CODEX_PROFILE: 'sleeping-stale' }
}
}
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
await new Promise((resolve) => setTimeout(resolve, 70))
expect(mockStoreState.getAgentLaunchConfigForStatusEntry).toHaveBeenCalledWith(
expect.objectContaining({ paneKey, agentType: 'codex' })
)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--model' 'current' 'resume' 'codex-session-1'"
})
)
expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(
paneKey,
expect.objectContaining({
agentArgs: '--model current'
}),
expect.objectContaining({
agentType: 'codex',
tabId: 'tab-1',
leafId: LEAF_1
})
)
})
it('shows the restored banner when a sleeping resume falls back to a fresh shell', async () => {
const { connectPanePty } = await import('./pty-connection')
const staleSessionId = 'wt-1@@stale-session'
@@ -3712,6 +4160,59 @@ describe('connectPanePty', () => {
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('keeps sleeping resume record when fresh cold-restore spawn fails', async () => {
const { connectPanePty } = await import('./pty-connection')
const staleSessionId = 'wt-1@@stale-session'
const transport = createMockTransport()
transport.connect.mockResolvedValue(undefined)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_2)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: staleSessionId }]
},
ptyIdsByTabId: {
'tab-1': [staleSessionId]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: staleSessionId }
}
},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
connectPanePty(
createPane(2) as never,
createManager(2) as never,
createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId }
}) as never
)
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
})
it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-1')
@@ -6055,6 +6556,25 @@ describe('connectPanePty', () => {
expect(transport.attach).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)),
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1',
ORCA_WORKSPACE_ID: 'wt-1',
ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`))
})
})
)
expect(transport.connect).toHaveBeenCalledWith(
expect.not.objectContaining({ sessionId: expect.any(String) })
)
@@ -6062,9 +6582,7 @@ describe('connectPanePty', () => {
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId)
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
expect(transport.sendInput).not.toHaveBeenCalled()
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
@@ -60,6 +60,7 @@ import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output
import { isLocalNativeWindowsPty } from '@/lib/pane-manager/windows-pty-compatibility'
import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { e2eConfig } from '@/lib/e2e-config'
@@ -107,8 +108,10 @@ import {
} from '../../../../shared/tui-agent-launch-defaults'
import {
isResumableTuiAgent,
normalizeAgentProviderSession
normalizeAgentProviderSession,
type ResumableTuiAgent
} from '../../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../../shared/types'
import { isWslUncPath } from '../../../../shared/wsl-paths'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
@@ -180,6 +183,19 @@ type E2eTerminalPtyOutputDebugWindow = Window & {
__terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi
}
type PendingStartupCommand = {
command: string
env?: Record<string, string>
}
type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
launchToken: string
useLiveEntry: boolean
hasSleepingRecord: boolean
}
const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: 0,
hiddenRendererSkippedChars: 0,
@@ -821,6 +837,36 @@ export function connectPanePty(
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
const launchToken = paneStartup?.launchConfig
? (paneStartup.launchToken ?? createBrowserUuid())
: undefined
if (paneStartup?.launchConfig) {
useAppStore.getState().registerAgentLaunchConfig(cacheKey, paneStartup.launchConfig, {
agentType: paneStartup.launchAgent ?? paneStartup.initialAgentStatus?.agent,
...(launchToken ? { launchToken } : {}),
tabId: deps.tabId,
leafId: pane.leafId
})
} else if (paneStartup) {
useAppStore.getState().clearAgentLaunchConfig(cacheKey)
}
const registerEffectiveLaunchConfig = (
effectiveLaunchConfig: PtyConnectResult['launchConfig'] | undefined,
metadata?: { launchToken?: string; launchAgent?: TuiAgent }
): void => {
if (!effectiveLaunchConfig) {
return
}
useAppStore.getState().registerAgentLaunchConfig(cacheKey, effectiveLaunchConfig, {
agentType:
metadata?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent,
...((metadata?.launchToken ?? launchToken)
? { launchToken: metadata?.launchToken ?? launchToken }
: {}),
tabId: deps.tabId,
leafId: pane.leafId
})
}
const pendingSpawnKey = cacheKey
const neutralTerminalTitle = (): string => {
const state = useAppStore.getState()
@@ -949,12 +995,16 @@ export function connectPanePty(
entry: AgentStatusEntry | undefined,
options?: { allowInferredInterrupt?: boolean }
): void => {
const state = useAppStore.getState()
if (!entry) {
// Why: an Orca-started agent can exit before its first hook status. The
// launch registry was still created up front, so clear it on command exit.
state.clearAgentLaunchConfig(cacheKey)
return
}
const state = useAppStore.getState()
const current = state.agentStatusByPaneKey[cacheKey]
if (!current) {
state.clearAgentLaunchConfig(cacheKey)
return
}
const unchanged =
@@ -1246,15 +1296,21 @@ export function connectPanePty(
if (!initialStatus) {
return
}
useAppStore.getState().setAgentStatus(
cacheKey,
{
state: 'working',
prompt: initialStatus.prompt,
agentType: initialStatus.agent
},
terminalTitle
)
const statusPayload = {
state: 'working' as const,
prompt: initialStatus.prompt,
agentType: initialStatus.agent
}
if (paneStartup.launchConfig) {
useAppStore
.getState()
.setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, undefined, {
launchConfig: paneStartup.launchConfig,
...(launchToken ? { launchToken } : {})
})
return
}
useAppStore.getState().setAgentStatus(cacheKey, statusPayload, terminalTitle)
}
const seedCommandCodeOutputWorkingStatus = (prompt: string): void => {
@@ -1592,12 +1648,16 @@ export function connectPanePty(
workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId
workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath
}
const paneEnv = {
...paneStartup?.env,
const paneIdentityEnv = {
...workspaceEnv,
ORCA_PANE_KEY: cacheKey,
ORCA_TAB_ID: deps.tabId,
ORCA_WORKTREE_ID: deps.worktreeId
ORCA_WORKTREE_ID: deps.worktreeId,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const paneEnv = {
...paneStartup?.env,
...paneIdentityEnv
}
// Why: folder workspaces can inherit their SSH target from child repos, so
@@ -1668,6 +1728,9 @@ export function connectPanePty(
activate: deps.isActiveRef.current && deps.isVisibleRef.current,
...(shellOverride ? { shellOverride } : {}),
...(projectRuntime ? { projectRuntime } : {}),
...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}),
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
onPtyExit: onExit,
onTitleChange,
@@ -1690,11 +1753,14 @@ export function connectPanePty(
// be stored against a title that was never paired with it.
const currentState = useAppStore.getState()
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
currentState.setAgentStatus(
cacheKey,
payload,
resolveAgentStatusTerminalTitle(payload, title)
)
const statusTitle = resolveAgentStatusTerminalTitle(payload, title)
if (launchToken) {
currentState.setAgentStatus(cacheKey, payload, statusTitle, undefined, undefined, {
launchToken
})
} else {
currentState.setAgentStatus(cacheKey, payload, statusTitle)
}
if (syncAgentTaskCompleteTrackingEnabled()) {
const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey]
const notificationPayload =
@@ -1982,8 +2048,12 @@ export function connectPanePty(
// Why: for ordinary local startup commands, the local PTY provider already
// writes via the shell-ready barrier. terminal-paste and SSH startup
// commands stay renderer-delivered so xterm/relay can apply their handling.
let pendingStartupCommand =
shouldDeliverStartupViaTerminalPaste || connectionId ? (paneStartup?.command ?? null) : null
let pendingStartupCommand: PendingStartupCommand | null =
shouldDeliverStartupViaTerminalPaste || connectionId
? paneStartup?.command
? { command: paneStartup.command }
: null
: null
const shouldWaitForSshShellReady =
Boolean(connectionId) &&
shouldUseShellReadyStartupDelivery({
@@ -2026,9 +2096,9 @@ export function connectPanePty(
}
return CLIENT_PLATFORM
}
const prepareColdRestoreAgentResumeCommand = (): boolean => {
const buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => {
if (pendingStartupCommand) {
return false
return null
}
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
@@ -2036,36 +2106,103 @@ export function connectPanePty(
const useLiveEntry = entry && entry.state !== 'done'
const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent
if (!agent || !isResumableTuiAgent(agent)) {
return false
return null
}
const providerSession = normalizeAgentProviderSession(
useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession
)
if (!providerSession) {
return false
return null
}
const matchingSleepingLaunchConfig =
sleepingRecord?.launchConfig &&
(!useLiveEntry ||
(sleepingRecord.agent === agent &&
sleepingRecord.providerSession.key === providerSession.key &&
sleepingRecord.providerSession.id === providerSession.id))
? sleepingRecord.launchConfig
: undefined
const launchConfig =
(useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
matchingSleepingLaunchConfig
const resumePlatform = getColdRestoreAgentResumePlatform()
const startupPlan = buildAgentResumeStartupPlan({
agent,
providerSession,
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv),
platform: getColdRestoreAgentResumePlatform()
agentArgs:
launchConfig !== undefined
? launchConfig.agentArgs
: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs),
agentEnv:
launchConfig !== undefined
? launchConfig.agentEnv
: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv),
...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
platform: resumePlatform
})
if (!startupPlan) {
return null
}
const coldRestoreLaunchToken = createBrowserUuid()
// Why: cold restore means the PTY process is gone but the agent provider
// session is still resumable, so the replacement spawn must launch it.
return {
agent,
command: startupPlan.launchCommand,
env: {
...startupPlan.env,
ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken
},
launchConfig: startupPlan.launchConfig,
launchToken: coldRestoreLaunchToken,
useLiveEntry: Boolean(useLiveEntry),
hasSleepingRecord: Boolean(sleepingRecord)
}
}
const applyColdRestoreAgentResumeStartup = (
startup: ColdRestoreAgentResumeStartup | null
): boolean => {
if (!startup) {
return false
}
// Why: cold restore means the PTY process is gone but the agent provider
// session is still resumable, so the replacement shell must launch it.
pendingStartupCommand = startupPlan.launchCommand
if (sleepingRecord) {
const state = useAppStore.getState()
if (startup.hasSleepingRecord) {
showSessionRestoredBanner()
}
if (!useLiveEntry && sleepingRecord) {
state.clearSleepingAgentSession(cacheKey)
}
state.registerAgentLaunchConfig(cacheKey, startup.launchConfig, {
agentType: startup.agent,
launchToken: startup.launchToken,
tabId: deps.tabId,
leafId: pane.leafId
})
return true
}
const clearSleepingRecordAfterColdRestoreSpawn = (
startup: ColdRestoreAgentResumeStartup | null
): void => {
if (startup && !startup.useLiveEntry && startup.hasSleepingRecord) {
useAppStore.getState().clearSleepingAgentSession(cacheKey)
}
}
const mergeStartupEnvWithPaneIdentity = (
env: Record<string, string> | undefined
): Record<string, string> | undefined =>
env
? {
...env,
...paneIdentityEnv,
...(env.ORCA_AGENT_LAUNCH_TOKEN
? { ORCA_AGENT_LAUNCH_TOKEN: env.ORCA_AGENT_LAUNCH_TOKEN }
: {})
}
: undefined
const startFreshColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup()
): void => {
applyColdRestoreAgentResumeStartup(startup)
startFreshSpawn(startup)
}
const isStartupPasteTargetCurrent = (ptyId: string | null): boolean =>
!disposed &&
deps.paneTransportsRef.current.get(pane.id) === transport &&
@@ -2114,16 +2251,17 @@ export function connectPanePty(
startupInjectTimer = setTimeout(() => {
startupInjectTimer = null
void (async () => {
const command = pendingStartupCommand
if (!command || disposed) {
const startup = pendingStartupCommand
if (!startup || disposed) {
return
}
if (shouldDeliverStartupViaTerminalPaste) {
await waitForTerminalOutputParsed(pane.terminal)
}
if (pendingStartupCommand !== command || disposed) {
if (pendingStartupCommand !== startup || disposed) {
return
}
const command = startup.command
if (shouldDeliverStartupViaTerminalPaste) {
await runTerminalPasteStartupCommand(command)
} else {
@@ -2134,9 +2272,18 @@ export function connectPanePty(
}, 50)
}
const startFreshSpawn = (): void => {
const startFreshSpawn = (startupOverride?: PendingStartupCommand | null): void => {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
if (connectionId && startupOverride?.command) {
// Why: SSH providers use `command` only as spawn metadata; the renderer
// must still submit the resume command to the fresh remote shell.
pendingStartupCommand = { command: startupOverride.command }
}
const coldRestoreOverride =
startupOverride && 'launchConfig' in startupOverride
? (startupOverride as ColdRestoreAgentResumeStartup)
: null
// Why: pre-signal the main process so its cooperation gate suppresses
// the daemon-snapshot seed for this paneKey. We issue declare and the
// spawn back-to-back without awaiting, because Electron's
@@ -2152,6 +2299,13 @@ export function connectPanePty(
url: '',
cols,
rows,
...(startupOverride?.command ? { command: startupOverride.command } : {}),
...(startupOverride?.env
? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) }
: {}),
...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}),
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -2162,7 +2316,20 @@ export function connectPanePty(
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
.then(async (spawnedPtyId) => {
const resolvedPtyId =
typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId()
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
? spawnedPtyId.id
: typeof spawnedPtyId === 'string'
? spawnedPtyId
: transport.getPtyId()
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {})
})
}
if (resolvedPtyId) {
clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride)
}
const gen = await preSignalPromise
if (typeof gen === 'number' && resolvedPtyId) {
if (!isRemoteRuntimePtyId(resolvedPtyId)) {
@@ -3108,7 +3275,8 @@ export function connectPanePty(
const handleReattachResult = (
result: PtyConnectResult | string | void,
staleSessionId?: string | null
staleSessionId?: string | null,
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
): void => {
if (disposed) {
return
@@ -3132,10 +3300,13 @@ export function connectPanePty(
if (staleSessionId) {
deps.clearTabPtyId(deps.tabId, staleSessionId)
}
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
registerEffectiveLaunchConfig(connectResult?.launchConfig, {
...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(coldRestoreStartup ? { launchAgent: coldRestoreStartup.agent } : {})
})
if (connectResult?.sessionExpired) {
deps.syncPanePtyLayoutBinding(pane.id, null)
if (staleSessionId) {
@@ -3144,8 +3315,7 @@ export function connectPanePty(
// Why: SSH sleep/reconnect can invalidate the relay-held PTY while
// leaving the tab mounted. Replace the dead lease in-place instead of
// stranding the pane behind a stale expired-session overlay.
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
setPanePtyFitBinding(ptyId)
@@ -3206,7 +3376,11 @@ export function connectPanePty(
// land in the new shell's stdin. See replay-guard.ts.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.coldRestore.scrollback)
const didPrepareResume = prepareColdRestoreAgentResumeCommand()
const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup()
const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup)
if (didPrepareResume) {
clearSleepingRecordAfterColdRestoreSpawn(preparedStartup)
}
// Cold-restore means the daemon lost the session and spawned a
// fresh shell — no TUI is consuming the mode-setting bytes that a
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
@@ -3215,7 +3389,7 @@ export function connectPanePty(
if (!isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
if (didPrepareResume) {
if (didPrepareResume && !coldRestoreStartup) {
schedulePendingStartupCommandDelivery()
}
}
@@ -3400,6 +3574,7 @@ export function connectPanePty(
? Promise.resolve(null)
: window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
let expiredReattachError = false
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
clearPaneMode2031State()
clearHiddenOutputRestoreState()
const reattachPromise = transport.connect({
@@ -3407,6 +3582,17 @@ export function connectPanePty(
cols,
rows,
sessionId: pendingSessionId,
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
...(coldRestoreStartup?.env
? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
: {}),
...(coldRestoreStartup?.launchConfig
? { launchConfig: coldRestoreStartup.launchConfig }
: {}),
...(coldRestoreStartup?.launchToken
? { launchToken: coldRestoreStartup.launchToken }
: {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -3440,11 +3626,10 @@ export function connectPanePty(
}
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
handleReattachResult(result, pendingSessionId)
handleReattachResult(result, pendingSessionId, coldRestoreStartup)
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!isRemoteRuntimePtyId(pendingSessionId)) {
@@ -3464,15 +3649,13 @@ export function connectPanePty(
if (isSshSessionExpiredError(err)) {
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
})
} else {
startFreshSpawn()
startFreshColdRestoreAgentResume()
}
})()
return
@@ -3513,10 +3696,12 @@ export function connectPanePty(
restoredSessionId && restoredSessionId !== detachedLivePtyId
? restoredSessionId
: detachedLivePtyId
const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId
? buildColdRestoreAgentResumeStartup()
: null
if (sleptRemoteRuntimeSessionId) {
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, sleptRemoteRuntimeSessionId)
prepareColdRestoreAgentResumeCommand()
}
const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[deps.tabId] ?? []
const candidateHasEagerBuffer = Boolean(
@@ -3570,11 +3755,21 @@ export function connectPanePty(
: window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
let expiredReattachError = false
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
const reattachPromise = transport.connect({
url: '',
cols,
rows,
sessionId: deferredReattachSessionId,
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
...(coldRestoreStartup?.env
? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
: {}),
...(coldRestoreStartup?.launchConfig
? { launchConfig: coldRestoreStartup.launchConfig }
: {}),
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -3600,11 +3795,10 @@ export function connectPanePty(
}
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
handleReattachResult(result, deferredReattachSessionId)
handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup)
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
@@ -3629,13 +3823,11 @@ export function connectPanePty(
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
if (connectionId && isSshSessionExpiredError(err)) {
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
return
}
reportError(message)
prepareColdRestoreAgentResumeCommand()
startFreshSpawn()
startFreshColdRestoreAgentResume(coldRestoreStartup)
})
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
// Why: mirrored web terminal layouts mount one pane per host leaf.
@@ -3733,7 +3925,7 @@ export function connectPanePty(
})
} else {
recordPtyConnectDiagnostic(`pane=${pane.id} -> FRESH SPAWN`)
startFreshSpawn()
startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup)
}
}
scheduleRuntimeGraphSync()
@@ -9,6 +9,8 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-t
import type { EventProps } from '../../../../shared/telemetry-events'
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../../shared/types'
import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate'
import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp'
@@ -294,6 +296,7 @@ export function registerEagerPtyBuffer(
export type PtyConnectResult = {
id: string
launchConfig?: SleepingAgentLaunchConfig
snapshot?: string
snapshotCols?: number
snapshotRows?: number
@@ -311,6 +314,12 @@ export type PtyTransport = {
/** Daemon session ID for reattach. When provided, the daemon reconnects
* to an existing session instead of creating a new one. */
sessionId?: string
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
callbacks: {
onConnect?: () => void
onDisconnect?: () => void
@@ -369,6 +378,9 @@ export type IpcPtyTransportOptions = {
cwd?: string
env?: Record<string, string>
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
/** Orca worktree identity for scoped shell history. */
@@ -435,6 +435,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
cwd,
env,
command,
launchConfig,
launchToken,
launchAgent,
startupCommandDelivery,
connectionId,
worktreeId,
@@ -667,9 +670,20 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd,
env,
command,
...(startupCommandDelivery ? { startupCommandDelivery } : {}),
env: options.env ?? env,
command: options.command ?? command,
...((options.launchConfig ?? launchConfig)
? { launchConfig: options.launchConfig ?? launchConfig }
: {}),
...((options.launchToken ?? launchToken)
? { launchToken: options.launchToken ?? launchToken }
: {}),
...((options.launchAgent ?? launchAgent)
? { launchAgent: options.launchAgent ?? launchAgent }
: {}),
...((options.startupCommandDelivery ?? startupCommandDelivery)
? { startupCommandDelivery: options.startupCommandDelivery ?? startupCommandDelivery }
: {}),
...(connectionId ? { connectionId } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
worktreeId,
@@ -706,6 +720,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) {
return {
id: spawnResult.id,
...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}),
snapshot: spawnResult.snapshot,
snapshotCols: spawnResult.snapshotCols,
snapshotRows: spawnResult.snapshotRows,
@@ -715,6 +730,12 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
replay: spawnResult.replay
} satisfies PtyConnectResult
}
if (spawnResult.launchConfig) {
return {
id: spawnResult.id,
launchConfig: spawnResult.launchConfig
} satisfies PtyConnectResult
}
return spawnResult.id
} catch (err) {
const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err))
@@ -371,6 +371,49 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('prefers connect-time launch metadata when creating the remote runtime terminal', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1',
command: "codex 'old'",
launchConfig: { agentArgs: '--old', agentEnv: {} },
launchToken: 'old-token',
launchAgent: 'codex'
})
await transport.connect({
url: '',
command: "codex '--model' 'gpt-5' 'resume' 'session-1'",
env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' },
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchToken: 'fresh-token',
launchAgent: 'codex',
callbacks: {}
})
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'terminal.create',
params: expect.objectContaining({
command: "codex '--model' 'gpt-5' 'resume' 'session-1'",
env: { CODEX_PROFILE: 'captured', ORCA_AGENT_LAUNCH_TOKEN: 'fresh-token' },
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchToken: 'fresh-token',
launchAgent: 'codex'
})
})
)
})
it('activates pending host session mirrors instead of creating duplicate terminals', async () => {
runtimeCall.mockImplementation((args) => {
if (args.method === 'session.tabs.activate') {
@@ -53,6 +53,9 @@ export function createRemoteRuntimePtyTransport(
command,
startupCommandDelivery,
env,
launchConfig,
launchToken,
launchAgent,
worktreeId,
tabId,
leafId,
@@ -403,9 +406,12 @@ export function createRemoteRuntimePtyTransport(
const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: toRuntimeWorktreeSelector(worktreeId),
command,
startupCommandDelivery,
env,
command: options.command ?? command,
startupCommandDelivery: options.startupCommandDelivery ?? startupCommandDelivery,
env: options.env ?? env,
launchConfig: options.launchConfig ?? launchConfig,
launchToken: options.launchToken ?? launchToken,
launchAgent: options.launchAgent ?? launchAgent,
tabId,
leafId,
focus: false,
@@ -3062,6 +3062,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: tuiAgent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -3127,6 +3129,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
startup: {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: tuiAgent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -3410,6 +3414,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
@@ -3444,6 +3449,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
...(agent ? { launchAgent: agent } : {}),
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
+47 -2
View File
@@ -8,6 +8,8 @@ import {
resolveBrowserSessionTabTarget,
resolveZoomTarget
} from './useIpcEvents'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../shared/types'
import { makePaneKey } from '../../../shared/stable-pane-id'
const { closeTerminalTabMock } = vi.hoisted(() => ({
@@ -1246,6 +1248,8 @@ describe('useIpcEvents updater integration', () => {
const revealWorktreeInSidebar = vi.fn()
const setTabCustomTitle = vi.fn()
const queueTabStartupCommand = vi.fn()
const registerAgentLaunchConfig = vi.fn()
const clearAgentLaunchConfig = vi.fn()
const updateTabPtyId = vi.fn()
const setTabLayout = vi.fn()
const setTabBarOrder = vi.fn()
@@ -1265,6 +1269,8 @@ describe('useIpcEvents updater integration', () => {
revealWorktreeInSidebar,
setTabCustomTitle,
queueTabStartupCommand,
registerAgentLaunchConfig,
clearAgentLaunchConfig,
updateTabPtyId,
setTabLayout,
tabsByWorktree: {} as Record<string, { id: string; ptyId?: string | null; title?: string }[]>,
@@ -1304,6 +1310,8 @@ describe('useIpcEvents updater integration', () => {
requestId?: string
worktreeId: string
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
title?: string
ptyId?: string
activate?: boolean
@@ -1328,6 +1336,8 @@ describe('useIpcEvents updater integration', () => {
afterTabId?: string
targetGroupId?: string
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
title?: string
activate?: boolean
}) => void)
@@ -1422,6 +1432,8 @@ describe('useIpcEvents updater integration', () => {
requestId?: string
worktreeId: string
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
title?: string
ptyId?: string
activate?: boolean
@@ -1447,6 +1459,8 @@ describe('useIpcEvents updater integration', () => {
afterTabId?: string
targetGroupId?: string
command?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
title?: string
activate?: boolean
}) => void
@@ -1602,6 +1616,11 @@ describe('useIpcEvents updater integration', () => {
targetGroupId: 'group-left',
title: 'Codex',
command: 'codex',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'request' }
},
launchAgent: 'codex',
activate: false
})
@@ -1623,7 +1642,14 @@ describe('useIpcEvents updater integration', () => {
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex', {
recordInteraction: false
})
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'codex' })
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', {
command: 'codex',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'request' }
},
launchAgent: 'codex'
})
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-renderer-backed',
tabId: 'tab-new',
@@ -1631,15 +1657,34 @@ describe('useIpcEvents updater integration', () => {
})
createTab.mockClear()
registerAgentLaunchConfig.mockClear()
createTerminalListenerRef.current({
worktreeId: 'wt-2',
ptyId: 'pty-bg'
ptyId: 'pty-bg',
leafId: '55555555-5555-4555-8555-555555555555',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'adopted' }
},
launchAgent: 'codex'
})
expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, {
initialPtyId: 'pty-bg',
activate: true
})
expect(registerAgentLaunchConfig).toHaveBeenCalledWith(
makePaneKey('tab-new', '55555555-5555-4555-8555-555555555555'),
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'adopted' }
},
{
agentType: 'codex',
tabId: 'tab-new',
leafId: '55555555-5555-4555-8555-555555555555'
}
)
createTab.mockClear()
setActiveView.mockClear()
+43 -3
View File
@@ -81,7 +81,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
import { detectLanguage } from '@/lib/language-detect'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
import { track } from '@/lib/telemetry'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
@@ -1205,6 +1205,10 @@ export function useIpcEvents(): void {
requestId,
worktreeId,
command,
env,
launchConfig,
launchToken,
launchAgent,
title,
ptyId,
activate,
@@ -1287,6 +1291,19 @@ export function useIpcEvents(): void {
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
}
if (leafId && ptyId) {
const launchPaneKey = tryMakePaneKey(tab.id, leafId)
if (launchConfig) {
if (launchPaneKey) {
store.registerAgentLaunchConfig(launchPaneKey, launchConfig, {
...(launchAgent ? { agentType: launchAgent } : {}),
...(launchToken ? { launchToken } : {}),
tabId: tab.id,
leafId
})
}
} else if (!splitFromLeafId && launchPaneKey) {
store.clearAgentLaunchConfig(launchPaneKey)
}
if (splitFromLeafId) {
// Why: runtime-spawned split PTYs already carry the parent tab's
// paneKey. Reusing the existing tab preserves native split-pane
@@ -1341,7 +1358,13 @@ export function useIpcEvents(): void {
}
}
if (command) {
store.queueTabStartupCommand(tab.id, { command })
store.queueTabStartupCommand(tab.id, {
command,
...(env ? { env } : {}),
...(launchConfig ? { launchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchAgent ? { launchAgent } : {})
})
}
if (requestId) {
window.api.ui.replyTerminalCreate({
@@ -1448,6 +1471,10 @@ export function useIpcEvents(): void {
if (data.command) {
store.queueTabStartupCommand(tab.id, {
command: data.command,
...(data.env ? { env: data.env } : {}),
...(data.launchConfig ? { launchConfig: data.launchConfig } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {}),
...(data.launchAgent ? { launchAgent: data.launchAgent } : {}),
...(data.startupCommandDelivery
? { startupCommandDelivery: data.startupCommandDelivery }
: {})
@@ -2738,7 +2765,12 @@ export function useIpcEvents(): void {
worktreeId: statusWorktreeId,
terminalHandle: data.terminalHandle
},
data.providerSession ? { providerSession: data.providerSession } : undefined
data.providerSession || data.launchToken
? {
...(data.providerSession ? { providerSession: data.providerSession } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {})
}
: undefined
)
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
if (options?.replay !== true && statusWorktreeId) {
@@ -3010,6 +3042,14 @@ function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boole
)
}
function tryMakePaneKey(tabId: string, leafId: string): string | null {
try {
return makePaneKey(tabId, leafId)
} catch {
return null
}
}
function applyResolvedAgentTerminalTitleToTab(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string,
+2 -2
View File
@@ -4803,7 +4803,7 @@
"a05bcdaf57": "Agents View",
"f63ea281e3": "Threaded left-sidebar feed for agent completions and blocking states.",
"agentHibernation": {
"copy": "Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again. Experimental while we tune the safety model.",
"copy": "Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again. Agent sleep preserves launch options for agents started by Orca. Manually started agents may resume with your current Orca defaults. Experimental while we tune the safety model.",
"description": "Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again.",
"idleMinutesDescription": "How many idle minutes a completed background agent must wait before Orca can sleep it.",
"idleMinutesLabel": "Sleep after",
@@ -6900,7 +6900,7 @@
"agentHibernation": {
"agent": "agent",
"agents": "agents",
"description": "Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again.",
"description": "Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again. Agent sleep preserves launch options for agents started by Orca; manually started agents may resume with current Orca defaults.",
"minutes": "minutes",
"sleep": "sleep",
"terminal": "terminal",
+2 -2
View File
@@ -4806,7 +4806,7 @@
"dd6f0a1d45": "Mascota",
"0e89a574ae": "Mascota animada flotante en la esquina inferior derecha.",
"agentHibernation": {
"copy": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando las vuelves a abrir. Experimental mientras ajustamos el modelo de seguridad.",
"copy": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando las vuelves a abrir. La suspensión de agentes conserva las opciones de inicio de los agentes iniciados por Orca. Los agentes iniciados manualmente pueden reanudarse con tus valores predeterminados actuales de Orca. Experimental mientras ajustamos el modelo de seguridad.",
"description": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando las vuelves a abrir.",
"idleMinutesDescription": "Cuántos minutos inactivo debe esperar un agente en segundo plano completado antes de que Orca pueda suspenderlo.",
"idleMinutesLabel": "Suspender después de",
@@ -6880,7 +6880,7 @@
"agentHibernation": {
"agent": "agente",
"agents": "agentes",
"description": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando se vuelven a abrir.",
"description": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando se vuelven a abrir. La suspensión de agentes conserva las opciones de inicio de los agentes iniciados por Orca; los agentes iniciados manualmente pueden reanudarse con los valores predeterminados actuales de Orca.",
"minutes": "minutos",
"sleep": "suspender",
"terminal": "terminal",
+2 -2
View File
@@ -4791,7 +4791,7 @@
"dd6f0a1d45": "ペット",
"0e89a574ae": "右下隅に浮かぶアニメーションのペット。",
"agentHibernation": {
"copy": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、対応しているセッションは再度開いたときに再開します。安全モデルを調整中の実験的機能です。",
"copy": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、対応しているセッションは再度開いたときに再開します。Agent のスリープは、Orca から起動した agent の起動オプションを保持します。手動で起動した agent は、現在の Orca のデフォルトで再開される場合があります。安全モデルを調整中の実験的機能です。",
"description": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、対応しているセッションは再度開いたときに再開します。",
"idleMinutesDescription": "完了したバックグラウンド agent を Orca がスリープ状態にできるまで待つアイドル時間(分)。",
"idleMinutesLabel": "スリープまで",
@@ -6902,7 +6902,7 @@
"agentHibernation": {
"agent": "agent",
"agents": "agents",
"description": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、再度開いたときに対応セッションを再開します。",
"description": "設定したアイドル時間が経過したバックグラウンド agent terminals を停止し、再度開いたときに対応セッションを再開します。Agent のスリープは Orca から起動した agent の起動オプションを保持します。手動で起動した agent は現在の Orca のデフォルトで再開される場合があります。",
"minutes": "分",
"sleep": "スリープ",
"terminal": "terminal",
+2 -2
View File
@@ -4791,7 +4791,7 @@
"dd6f0a1d45": "애완 동물",
"0e89a574ae": "오른쪽 하단에 떠 있는 애니메이션 애완동물.",
"agentHibernation": {
"copy": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. 안전 모델을 조정하는 동안 제공되는 실험적 기능입니다.",
"copy": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. Agent 최대 절전은 Orca에서 시작한 agent의 시작 옵션을 보존합니다. 수동으로 시작한 agent는 현재 Orca 기본값으로 재개될 수 있습니다. 안전 모델을 조정하는 동안 제공되는 실험적 기능입니다.",
"description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.",
"idleMinutesDescription": "완료된 백그라운드 agent를 Orca가 최대 절전 모드로 전환하기 전에 기다릴 유휴 시간(분)입니다.",
"idleMinutesLabel": "최대 절전까지",
@@ -6865,7 +6865,7 @@
"agentHibernation": {
"agent": "agent",
"agents": "agents",
"description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다.",
"description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. Agent 최대 절전은 Orca에서 시작한 agent의 시작 옵션을 보존합니다. 수동으로 시작한 agent는 현재 Orca 기본값으로 재개될 수 있습니다.",
"minutes": "분",
"sleep": "절전",
"terminal": "terminal",
+2 -2
View File
@@ -4791,7 +4791,7 @@
"dd6f0a1d45": "宠物",
"0e89a574ae": "右下角漂浮的动画宠物。",
"agentHibernation": {
"copy": "在配置的空闲时间后停止后台 agent 终端,并在你再次打开时恢复受支持的会话。我们仍在调校安全模型,此功能为实验性功能。",
"copy": "在配置的空闲时间后停止后台 agent 终端,并在你再次打开时恢复受支持的会话。Agent 睡眠会保留由 Orca 启动的 agent 的启动选项。手动启动的 agent 可能会使用当前的 Orca 默认值恢复。我们仍在调校安全模型,此功能为实验性功能。",
"description": "在配置的空闲时间后停止后台 agent 终端,并在你再次打开时恢复受支持的会话。",
"idleMinutesDescription": "已完成的后台 agent 在 Orca 可以将其睡眠前需要等待的空闲分钟数。",
"idleMinutesLabel": "睡眠等待时间",
@@ -6865,7 +6865,7 @@
"agentHibernation": {
"agent": "agent",
"agents": "agents",
"description": "在配置的空闲时间后停止后台 agent 终端,并在再次打开时恢复受支持的会话。",
"description": "在配置的空闲时间后停止后台 agent 终端,并在再次打开时恢复受支持的会话。Agent 睡眠会保留由 Orca 启动的 agent 的启动选项;手动启动的 agent 可能会使用当前的 Orca 默认值恢复。",
"minutes": "分钟",
"sleep": "睡眠",
"terminal": "终端",
@@ -13,6 +13,7 @@ const mockSetTabCustomTitle = vi.fn()
const mockUpdateTabPtyId = vi.fn()
const mockCloseTab = vi.fn()
const mockSetTabLayout = vi.fn()
const mockRegisterAgentLaunchConfig = vi.fn()
const mockRegisterEagerPtyBuffer = vi.fn()
const mockSubscribeToPtyData = vi.fn()
const mockSubscribeToPtyExit = vi.fn()
@@ -66,7 +67,9 @@ const state = {
closeTab: mockCloseTab,
setTabLayout: mockSetTabLayout,
clearTabPtyId: vi.fn(),
setAgentStatus: vi.fn()
setAgentStatus: vi.fn(),
registerAgentLaunchConfig: mockRegisterAgentLaunchConfig,
clearAgentLaunchConfig: vi.fn()
}
vi.mock('@/store', () => ({
@@ -189,6 +192,18 @@ describe('launchAgentBackgroundSession', () => {
})
)
expect(mockSetTabLayout.mock.calls.at(-1)?.[1]).not.toHaveProperty('titlesByLeafId')
expect(mockSpawn.mock.calls[0]?.[0]).toMatchObject({
launchConfig: {
agentCommand: "claude '--dangerously-skip-permissions'",
agentArgs: '--dangerously-skip-permissions',
agentEnv: {}
},
launchAgent: 'claude',
launchToken: expect.stringMatching(UUID_RE)
})
expect(mockSpawn.mock.calls[0]?.[0].launchToken).toBe(
mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN
)
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', {
recordInteraction: false
})
@@ -199,6 +214,31 @@ describe('launchAgentBackgroundSession', () => {
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1' })
})
it('records effective launch config returned by local PTY spawn', async () => {
const effectiveLaunchConfig = {
agentCommand: "claude '--dangerously-skip-permissions'",
agentArgs: '--dangerously-skip-permissions',
agentEnv: { ORCA_AGENT_TEAMS_TEAM_ID: 'team-fresh' }
}
mockSpawn.mockResolvedValue({ id: 'pty-1', launchConfig: effectiveLaunchConfig })
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation'
})
const paneKey = expectStablePaneSpawn()
const leafId = paneKey.slice('tab-1:'.length)
expect(mockRegisterAgentLaunchConfig).toHaveBeenLastCalledWith(paneKey, effectiveLaunchConfig, {
agentType: 'claude',
launchToken: mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN,
tabId: 'tab-1',
leafId
})
})
it('uses WSL launch quoting for Windows-path projects forced to WSL', async () => {
state.projects = [
{
@@ -272,7 +312,10 @@ describe('launchAgentBackgroundSession', () => {
expect(state.setAgentStatus).toHaveBeenCalledWith(
paneKey,
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
undefined
undefined,
undefined,
undefined,
{ launchToken: expect.stringMatching(UUID_RE) }
)
expect(onAgentStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
@@ -289,11 +332,25 @@ describe('launchAgentBackgroundSession', () => {
})
const paneKey = expectStablePaneSpawn()
expect(state.setAgentStatus).toHaveBeenCalledWith(paneKey, {
state: 'working',
prompt: 'check the status spinner',
agentType: 'command-code'
})
expect(state.setAgentStatus).toHaveBeenCalledWith(
paneKey,
{
state: 'working',
prompt: 'check the status spinner',
agentType: 'command-code'
},
undefined,
undefined,
undefined,
{
launchConfig: {
agentCommand: "command-code --trust '--yolo'",
agentArgs: '--yolo',
agentEnv: {}
},
launchToken: expect.stringMatching(UUID_RE)
}
)
})
it('uses a sidecar exit watcher so completion survives terminal attachment', async () => {
@@ -313,6 +370,7 @@ describe('launchAgentBackgroundSession', () => {
sidecar(0)
expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/))
expect(onExit).toHaveBeenCalledWith('pty-1', 0)
expect(unsubscribe).toHaveBeenCalled()
})
@@ -502,6 +560,20 @@ describe('launchAgentBackgroundSession', () => {
const paneKey = params?.env?.ORCA_PANE_KEY
const leafId = typeof paneKey === 'string' ? paneKey.slice('tab-1:'.length) : ''
expect(leafId).toMatch(UUID_RE)
expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith(
`tab-1:${leafId}`,
{
agentCommand: "claude '--dangerously-skip-permissions'",
agentArgs: '--dangerously-skip-permissions',
agentEnv: {}
},
{
agentType: 'claude',
launchToken: expect.stringMatching(UUID_RE),
tabId: 'tab-1',
leafId
}
)
expect(mockSetTabLayout).toHaveBeenCalledWith(
'tab-1',
expect.objectContaining({
@@ -516,6 +588,7 @@ describe('launchAgentBackgroundSession', () => {
params: expect.objectContaining({
worktree: 'id:wt-1',
command: "claude '--dangerously-skip-permissions' 'run the automation'",
launchAgent: 'claude',
env: expect.objectContaining({
ORCA_PANE_KEY: `tab-1:${leafId}`,
ORCA_TAB_ID: 'tab-1',
@@ -130,6 +130,13 @@ export async function launchAgentBackgroundSession(
// browser contexts — the LAN web client served over plain HTTP.
const leafId = createBrowserUuid()
const paneKey = makePaneKey(tab.id, leafId)
const launchToken = createBrowserUuid()
store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, {
agentType: agent,
launchToken,
tabId: tab.id,
leafId
})
// Why: `title` labels the tab/worktree entry. Pane titles render as an
// in-terminal title row, so background sessions must not persist it there.
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
@@ -137,7 +144,8 @@ export async function launchAgentBackgroundSession(
...startupPlan.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tab.id,
ORCA_WORKTREE_ID: worktreeId
ORCA_WORKTREE_ID: worktreeId,
ORCA_AGENT_LAUNCH_TOKEN: launchToken
}
const sshConnectionId = repo?.connectionId ?? null
const sshStartupDelivery = createSshBackgroundStartupDelivery({
@@ -166,6 +174,9 @@ export async function launchAgentBackgroundSession(
{
worktree: toRuntimeWorktreeSelector(worktreeId),
command: startupPlan.launchCommand,
launchConfig: startupPlan.launchConfig,
launchToken,
launchAgent: agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -188,6 +199,9 @@ export async function launchAgentBackgroundSession(
? {}
: { startupCommandDelivery: startupPlan.startupCommandDelivery }),
env: paneEnv,
launchConfig: startupPlan.launchConfig,
launchToken,
launchAgent: agent,
connectionId: sshConnectionId,
worktreeId,
tabId: tab.id,
@@ -199,6 +213,14 @@ export async function launchAgentBackgroundSession(
}
})
ptyId = result.id
if (result.launchConfig) {
store.registerAgentLaunchConfig(paneKey, result.launchConfig, {
agentType: agent,
launchToken,
tabId: tab.id,
leafId
})
}
}
} catch (error) {
store.closeTab(tab.id, { recordInteraction: false })
@@ -209,11 +231,18 @@ export async function launchAgentBackgroundSession(
if (agent === 'command-code' && hasPrompt && !isFollowupPath) {
// Why: Command Code does not expose a prompt-start hook; seed working for
// hidden prompt launches so sidebar/activity surfaces do not stay idle.
store.setAgentStatus(paneKey, {
state: 'working',
prompt: trimmedPrompt,
agentType: agent
})
store.setAgentStatus(
paneKey,
{
state: 'working',
prompt: trimmedPrompt,
agentType: agent
},
undefined,
undefined,
undefined,
{ launchConfig: startupPlan.launchConfig, launchToken }
)
}
let exitHandled = false
let unsubscribeExit = (): void => {}
@@ -227,6 +256,7 @@ export async function launchAgentBackgroundSession(
unsubscribeData()
sshStartupDelivery.clear()
useAppStore.getState().clearTabPtyId(tab.id, ptyId)
useAppStore.getState().clearAgentLaunchConfig(paneKey)
onExit?.(ptyId, code)
}
const processAgentStatus = createAgentStatusOscProcessor()
@@ -236,7 +266,9 @@ export async function launchAgentBackgroundSession(
sshStartupDelivery.schedule(ptyId)
const processed = processAgentStatus(data)
for (const payload of processed.payloads) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, {
launchToken
})
onAgentStatus?.(payload)
}
}
@@ -13,7 +13,12 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const store = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null },
settings: {
agentCmdOverrides: {},
agentDefaultArgs: {} as Record<string, string>,
agentDefaultEnv: {} as Record<string, Record<string, string>>,
activeRuntimeEnvironmentId: null as string | null
},
projects: [
{
id: 'repo-1',
@@ -101,7 +106,12 @@ describe('launchAgentInNewTab', () => {
mockCreateWebRuntimeSessionTerminal.mockResolvedValue(true)
store.activeRepoId = 'repo-1'
store.activeWorktreeId = 'wt-1'
store.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null }
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null
}
store.projects = [
{
id: 'repo-1',
@@ -159,7 +169,12 @@ describe('launchAgentInNewTab', () => {
it('delegates agent quick launch to the host runtime in paired web clients', async () => {
mockIsWebRuntimeSessionActive.mockReturnValue(true)
store.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'web-runtime' }
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: 'web-runtime'
}
store.tabsByWorktree = {
'wt-1': [
{ id: 'tab-1' },
@@ -194,10 +209,57 @@ describe('launchAgentInNewTab', () => {
expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab')
})
it('forwards prompt launch env and captured config to paired web runtime hosts', async () => {
mockIsWebRuntimeSessionActive.mockReturnValue(true)
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--model gpt-5 --reasoning-effort high' },
agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } },
activeRuntimeEnvironmentId: 'web-runtime'
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
prompt: 'fix the spinner',
groupId: 'group-1'
})
expect(result).toEqual(
expect.objectContaining({
tabId: null,
pasteDraftAfterLaunch: false
})
)
expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'web-runtime',
targetGroupId: 'group-1',
activate: true,
command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'",
env: { CODEX_PROFILE: 'captured' },
startupCommandDelivery: 'shell-ready',
launchConfig: {
agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'",
agentArgs: '--model gpt-5 --reasoning-effort high',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex'
})
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockQueueTabStartupCommand).not.toHaveBeenCalled()
})
it('surfaces a toast when host agent launch fails in paired web clients', async () => {
mockIsWebRuntimeSessionActive.mockReturnValue(true)
mockCreateWebRuntimeSessionTerminal.mockResolvedValue(false)
store.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'web-runtime' }
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: 'web-runtime'
}
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({
@@ -181,6 +181,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
@@ -239,6 +240,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
...(hasPrompt
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {})
@@ -286,6 +290,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
store.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -18,12 +18,15 @@ describe('buildDirectWorkItemStartupOpts', () => {
launchCommand: "codex 'review linked issue'",
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
startupCommandDelivery: 'shell-ready'
}
expect(buildDirectWorkItemStartupOpts('codex', plan, 'task_page')).toEqual({
startup: {
command: "codex 'review linked issue'",
launchAgent: 'codex',
launchConfig: { agentArgs: '', agentEnv: {} },
startupCommandDelivery: 'shell-ready',
telemetry: {
agent_kind: 'codex',
@@ -3,6 +3,7 @@ import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { LaunchSource } from '../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../../shared/types'
@@ -16,6 +17,8 @@ export function buildDirectWorkItemStartupOpts(
startup?: {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
telemetry?: AgentStartedTelemetry
}
@@ -31,6 +34,8 @@ export function buildDirectWorkItemStartupOpts(
startup: {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
launchConfig: plan.launchConfig,
...(agent ? { launchAgent: agent } : {}),
...(plan.startupCommandDelivery
? { startupCommandDelivery: plan.startupCommandDelivery }
: {}),
@@ -339,7 +339,8 @@ describe('launchWorkItemDirect', () => {
agent: 'cursor',
launchCommand: 'cursor-agent',
expectedProcess: 'cursor-agent',
followupPrompt: null
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
})
mocks.store.createWorktree.mockResolvedValue({
worktree: { id: 'wt-ssh', path: '/home/orca/repo-worktrees/issue-77' }
@@ -91,10 +91,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
worktreePath: repo.path,
projectRuntime: repoProjectRuntime
})
const agentArgsPlan = planAgentCliArgsSuffix(
agentArgs,
preflightLaunchPlatform === 'win32' ? 'powershell' : 'posix'
)
const shell = preflightLaunchPlatform === 'win32' ? 'powershell' : 'posix'
const agentArgsPlan = planAgentCliArgsSuffix(agentArgs, shell)
if (!agentArgsPlan.ok) {
// Why: direct launches may create a worktree before the agent startup plan
// is built; reject malformed saved args before touching user workspaces.
@@ -297,6 +295,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
+8 -3
View File
@@ -191,7 +191,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
followupPrompt: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -208,7 +209,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
followupPrompt: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -225,7 +227,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner'
followupPrompt: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
).resolves.toBeUndefined()
@@ -241,6 +244,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'review this before sending'
}
})
@@ -266,6 +270,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'Linear context draft'
}
})
@@ -7,11 +7,14 @@ import {
} from '../../../shared/tui-agent-launch-defaults'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { GlobalSettings, OnboardingState } from '../../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../shared/types'
export type OnboardingFolderAgentStartup = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
telemetry: AgentStartedTelemetry
}
@@ -52,6 +55,8 @@ export function buildOnboardingFolderAgentStartup(
return {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -107,6 +107,40 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('uses captured launch config instead of changed settings when resuming worktree sleep', () => {
const record = makeRecord({
agent: 'codex',
origin: 'worktree-sleep',
launchConfig: {
agentCommand: "codex --profile captured '--model' 'gpt-5' '--reasoning-effort' 'high'",
agentArgs: '--model gpt-5 --reasoning-effort high',
agentEnv: { CODEX_PROFILE: 'captured' }
}
})
useAppStore.setState({
settings: {
agentCmdOverrides: { codex: 'codex --profile changed' },
agentDefaultArgs: { codex: '--model changed' },
agentDefaultEnv: { codex: { CODEX_PROFILE: 'changed' } }
},
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(1)
const state = useAppStore.getState()
const resumedTab = state.tabsByWorktree['wt-1']?.[0]
const startup = state.pendingStartupByTabId[resumedTab!.id]
expect(startup?.command).toBe(
"codex --profile captured '--model' 'gpt-5' '--reasoning-effort' 'high' 'resume' 'sess-1'"
)
expect(startup?.env).toEqual({ CODEX_PROFILE: 'captured' })
expect(startup?.command).not.toContain('changed')
expect(startup?.launchConfig).toEqual(record.launchConfig)
})
it('uses WSL resume quoting for Windows-path projects forced to WSL', () => {
const record = makeRecord({
providerSession: { key: 'session_id', id: "sess-1's" },
@@ -50,12 +50,20 @@ function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void {
function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean {
const state = useAppStore.getState()
const launchConfig = record.launchConfig
const startupPlan = buildAgentResumeStartupPlan({
agent: record.agent,
providerSession: record.providerSession,
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv),
agentArgs:
launchConfig !== undefined
? launchConfig.agentArgs
: resolveTuiAgentLaunchArgs(record.agent, state.settings?.agentDefaultArgs),
agentEnv:
launchConfig !== undefined
? launchConfig.agentEnv
: resolveTuiAgentLaunchEnv(record.agent, state.settings?.agentDefaultEnv),
...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
platform: getResumeLaunchPlatform(record.worktreeId)
})
if (!startupPlan) {
@@ -73,6 +81,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
})
state.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: record.agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -111,6 +111,7 @@ export function planSourceControlAgentActionLaunch(args: {
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
+44 -17
View File
@@ -6,6 +6,12 @@ import {
} from './tui-agent-startup'
import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults'
const emptyLaunchConfig = (agentCommand: string) => ({
agentCommand,
agentArgs: '',
agentEnv: {}
})
describe('buildAgentStartupPlan', () => {
it('passes Claude prompts as a positional interactive argument', () => {
expect(
@@ -19,7 +25,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'claude',
launchCommand: "claude 'Fix the bug'",
expectedProcess: 'claude',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('claude')
})
})
@@ -35,7 +42,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'gemini',
launchCommand: "gemini --prompt-interactive 'Investigate this regression'",
expectedProcess: 'gemini',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('gemini')
})
})
@@ -51,7 +59,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'antigravity',
launchCommand: "agy --prompt-interactive 'Investigate this regression'",
expectedProcess: 'agy',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('agy')
})
})
@@ -67,7 +76,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'Refactor the parser'
followupPrompt: 'Refactor the parser',
launchConfig: emptyLaunchConfig('aider')
})
})
@@ -83,7 +93,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'autohand',
launchCommand: 'autohand',
expectedProcess: 'autohand',
followupPrompt: 'Add tests for the parser'
followupPrompt: 'Add tests for the parser',
launchConfig: emptyLaunchConfig('autohand')
})
})
@@ -99,7 +110,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'ante',
launchCommand: 'ante',
expectedProcess: 'ante',
followupPrompt: 'Summarize the failing tests'
followupPrompt: 'Summarize the failing tests',
launchConfig: emptyLaunchConfig('ante')
})
})
@@ -115,7 +127,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'cursor',
launchCommand: "cursor-agent 'Review this file'",
expectedProcess: 'cursor-agent',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('cursor-agent')
})
})
@@ -131,7 +144,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'droid',
launchCommand: "/opt/factory/bin/droid 'Ship the fix'",
expectedProcess: 'droid',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('/opt/factory/bin/droid')
})
})
@@ -147,7 +161,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'copilot',
launchCommand: "copilot -i 'Fix the bug'",
expectedProcess: 'copilot',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('copilot')
})
})
@@ -163,7 +178,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'grok',
launchCommand: 'grok',
expectedProcess: 'grok',
followupPrompt: 'Trace the failing test'
followupPrompt: 'Trace the failing test',
launchConfig: emptyLaunchConfig('grok')
})
})
@@ -180,7 +196,12 @@ describe('buildAgentStartupPlan', () => {
agent: 'devin',
launchCommand: "devin '--permission-mode' 'bypass'",
expectedProcess: 'devin',
followupPrompt: 'Trace the failing test'
followupPrompt: 'Trace the failing test',
launchConfig: {
agentCommand: "devin '--permission-mode' 'bypass'",
agentArgs: '--permission-mode bypass',
agentEnv: {}
}
})
})
@@ -196,7 +217,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'command-code',
launchCommand: "command-code --trust 'Fix the issue'",
expectedProcess: 'command-code',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('command-code --trust')
})
})
@@ -223,7 +245,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'copilot',
launchCommand: "copilot -i 'Fix the bug'",
expectedProcess: 'copilot',
followupPrompt: null
followupPrompt: null,
launchConfig: emptyLaunchConfig('copilot')
})
})
})
@@ -240,7 +263,8 @@ describe('buildAgentDraftLaunchPlan', () => {
).toEqual({
agent: 'claude',
launchCommand: "claude --prefill 'https://github.com/acme/repo/issues/42'",
expectedProcess: 'claude'
expectedProcess: 'claude',
launchConfig: emptyLaunchConfig('claude')
})
})
@@ -272,7 +296,8 @@ describe('buildAgentDraftLaunchPlan', () => {
agent: 'pi',
launchCommand: 'pi; unset ORCA_PI_PREFILL',
expectedProcess: 'pi',
env: { ORCA_PI_PREFILL: 'https://github.com/acme/repo/issues/42' }
env: { ORCA_PI_PREFILL: 'https://github.com/acme/repo/issues/42' },
launchConfig: emptyLaunchConfig('pi')
})
})
@@ -298,7 +323,8 @@ describe('buildAgentDraftLaunchPlan', () => {
).toEqual({
agent: 'claude',
launchCommand: "/opt/anthropic/bin/claude --prefill 'review this'",
expectedProcess: 'claude'
expectedProcess: 'claude',
launchConfig: emptyLaunchConfig('/opt/anthropic/bin/claude')
})
})
@@ -313,7 +339,8 @@ describe('buildAgentDraftLaunchPlan', () => {
).toEqual({
agent: 'openclaude',
launchCommand: "openclaude --prefill 'review this'",
expectedProcess: 'openclaude'
expectedProcess: 'openclaude',
launchConfig: emptyLaunchConfig('openclaude')
})
})
})
+3 -1
View File
@@ -3,7 +3,9 @@ export {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planAgentCliArgsSuffix,
isShellProcess
isShellProcess,
quoteStartupArg,
resolveStartupShell
} from '../../../shared/tui-agent-startup'
export type {
AgentCliArgsPlan,
@@ -0,0 +1,122 @@
import path from 'node:path'
import { vi } from 'vitest'
import type { Worktree } from '../../../shared/types'
import { useAppStore } from '@/store'
export function makeCreatedAgentWorktree(): Worktree {
const workspacePath = path.join(path.sep, 'workspace', 'feature')
return {
id: `repo-1::${workspacePath}`,
repoId: 'repo-1',
path: workspacePath,
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdWithAgent: 'codex'
}
}
export function seedAlreadyActiveWorktree(
worktree: Worktree,
overrides: Partial<ReturnType<typeof useAppStore.getState>> = {}
): {
markWorktreeVisited: ReturnType<typeof vi.fn>
recordWorktreeVisit: ReturnType<typeof vi.fn>
revealWorktreeInSidebar: ReturnType<typeof vi.fn>
} {
const markWorktreeVisited = vi.fn()
const recordWorktreeVisit = vi.fn()
const revealWorktreeInSidebar = vi.fn()
const terminalTitle = ['Terminal', '1'].join(' ')
const repoPath = path.join(path.sep, 'workspace', 'repo')
useAppStore.setState({
repos: [
{
id: worktree.repoId,
path: repoPath,
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { [worktree.repoId]: [worktree] },
activeRepoId: worktree.repoId,
activeView: 'terminal',
activeWorktreeId: worktree.id,
activeTabId: 'tab-1',
activeTabType: 'terminal',
tabsByWorktree: {
[worktree.id]: [
{
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: worktree.id,
title: terminalTitle,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
unifiedTabsByWorktree: {
[worktree.id]: [
{
id: 'tab-1',
entityId: 'tab-1',
groupId: 'group-1',
worktreeId: worktree.id,
contentType: 'terminal',
label: terminalTitle,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
groupsByWorktree: {
[worktree.id]: [
{
id: 'group-1',
worktreeId: worktree.id,
activeTabId: 'tab-1',
tabOrder: ['tab-1']
}
]
},
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
everActivatedWorktreeIds: new Set([worktree.id]),
openFiles: [],
browserTabsByWorktree: {},
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabIdByWorktree: { [worktree.id]: 'tab-1' },
tabBarOrderByWorktree: {},
settings: {
agentCmdOverrides: {},
setupScriptLaunchMode: 'new-tab'
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
markWorktreeVisited,
recordWorktreeVisit,
refreshGitHubForWorktreeIfStale: vi.fn(),
revealWorktreeInSidebar,
...overrides
})
return { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar }
}
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../shared/types'
import { getDefaultSettings } from '../../../shared/constants'
import { useAppStore } from '@/store'
import {
@@ -8,6 +7,10 @@ import {
} from './worktree-activation'
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
import {
makeCreatedAgentWorktree as makeWorktree,
seedAlreadyActiveWorktree
} from '@/lib/worktree-activation-created-agent-test-state'
const initialAppStoreState = useAppStore.getState()
@@ -19,121 +22,6 @@ afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
})
function makeWorktree(): Worktree {
return {
id: 'repo-1::/workspace/feature',
repoId: 'repo-1',
path: '/workspace/feature',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdWithAgent: 'codex'
}
}
function seedAlreadyActiveWorktree(
worktree: Worktree,
overrides: Partial<ReturnType<typeof useAppStore.getState>> = {}
): {
markWorktreeVisited: ReturnType<typeof vi.fn>
recordWorktreeVisit: ReturnType<typeof vi.fn>
revealWorktreeInSidebar: ReturnType<typeof vi.fn>
} {
const markWorktreeVisited = vi.fn()
const recordWorktreeVisit = vi.fn()
const revealWorktreeInSidebar = vi.fn()
useAppStore.setState({
repos: [
{
id: worktree.repoId,
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { [worktree.repoId]: [worktree] },
activeRepoId: worktree.repoId,
activeView: 'terminal',
activeWorktreeId: worktree.id,
activeTabId: 'tab-1',
activeTabType: 'terminal',
tabsByWorktree: {
[worktree.id]: [
{
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: worktree.id,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
unifiedTabsByWorktree: {
[worktree.id]: [
{
id: 'tab-1',
entityId: 'tab-1',
groupId: 'group-1',
worktreeId: worktree.id,
contentType: 'terminal',
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
groupsByWorktree: {
[worktree.id]: [
{
id: 'group-1',
worktreeId: worktree.id,
activeTabId: 'tab-1',
tabOrder: ['tab-1']
}
]
},
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
everActivatedWorktreeIds: new Set([worktree.id]),
openFiles: [],
browserTabsByWorktree: {},
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabIdByWorktree: { [worktree.id]: 'tab-1' },
tabBarOrderByWorktree: {},
settings: {
agentCmdOverrides: {},
setupScriptLaunchMode: 'new-tab'
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
markWorktreeVisited,
recordWorktreeVisit,
refreshGitHubForWorktreeIfStale: vi.fn(),
revealWorktreeInSidebar,
...overrides
})
return { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar }
}
describe('activateAndRevealWorktree created agent reopen', () => {
it('does not restamp focus recency when reselecting the already-active terminal worktree', () => {
const worktree = makeWorktree()
@@ -210,6 +98,13 @@ describe('activateAndRevealWorktree created agent reopen', () => {
expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
launchToken: expect.any(String),
telemetry: {
agent_kind: 'codex',
launch_source: 'sidebar',
@@ -358,6 +253,14 @@ describe('activateAndRevealWorktree created agent reopen', () => {
expect(resumedTab?.launchAgent).toBe('codex')
expect(state.pendingStartupByTabId[resumedTab!.id]).toEqual({
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
launchToken: expect.any(String),
showSessionRestoredBanner: true,
telemetry: {
agent_kind: 'codex',
@@ -9,6 +9,7 @@ import type {
} from '../../../shared/types'
import type { EventProps } from '../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { buildSetupRunnerCommand } from './setup-runner'
import { buildAgentStartupPlan } from './tui-agent-startup'
@@ -62,6 +63,9 @@ export type AgentStartedTelemetry = EventProps<'agent_started'>
export type WorktreeStartupPayload = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
initialAgentStatus?: { agent: TuiAgent; prompt: string }
telemetry?: AgentStartedTelemetry
@@ -103,6 +107,8 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
startup: {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
@@ -242,6 +248,8 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl
return {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: agent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
@@ -31,6 +31,8 @@ function buildStartupOpt(
return {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
launchConfig: plan.launchConfig,
...(request.agent ? { launchAgent: request.agent } : {}),
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
@@ -430,7 +430,13 @@ describe('createWebRuntimeSessionTerminal', () => {
afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1',
targetGroupId: 'group-left',
command: "codex 'linked issue context'",
env: { CODEX_PROFILE: 'captured' },
startupCommandDelivery: 'shell-ready',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
activate: true
})
).resolves.toBe(true)
@@ -443,7 +449,13 @@ describe('createWebRuntimeSessionTerminal', () => {
afterTabId: 'host-tab-1::leaf-1',
targetGroupId: 'group-left',
command: "codex 'linked issue context'",
env: { CODEX_PROFILE: 'captured' },
startupCommandDelivery: 'shell-ready',
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchAgent: 'codex',
activate: true
},
timeoutMs: 15_000
@@ -11,6 +11,7 @@ import type {
} from '../../../shared/runtime-types'
import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types'
import type { AppState } from '../store/types'
import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner'
@@ -48,8 +49,11 @@ export async function createWebRuntimeSessionTerminal(args: {
afterTabId?: string
targetGroupId?: string
command?: string
env?: Record<string, string>
startupCommandDelivery?: StartupCommandDelivery
launchConfig?: SleepingAgentLaunchConfig
agent?: TuiAgent
launchAgent?: TuiAgent
activate?: boolean
selectWorktree?: boolean
}): Promise<boolean> {
@@ -73,8 +77,11 @@ export async function createWebRuntimeSessionTerminal(args: {
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
targetGroupId: args.targetGroupId,
command: args.command,
...(args.env ? { env: args.env } : {}),
startupCommandDelivery: args.startupCommandDelivery,
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
agent: args.agent,
...(args.launchAgent ? { launchAgent: args.launchAgent } : {}),
activate: args.activate !== false
},
timeoutMs: 15_000
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AppState } from '../types'
import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status'
import { createTestStore, makeTab } from './store-test-helpers'
function makeAgentEntry(overrides: {
@@ -31,6 +32,49 @@ describe('captureAllSleepingAgentSessions', () => {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{ agentType: 'codex', launchToken: 'launch-token-1', tabId: 'tab-1', leafId: 'leaf-1' }
)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{
providerSession: { key: 'session_id', id: 'codex-session-1' },
launchToken: 'launch-token-1'
}
)
// Why: Windows update/reboot exits can miss beforeunload; the provider
// session handle must already be durable for pane-level cold restore.
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
agent: 'codex',
worktreeId: 'wt-1',
tabId: 'tab-1',
providerSession: { key: 'session_id', id: 'codex-session-1' },
origin: 'live'
})
})
it('captures launch config into live checkpoints and refreshes late registration', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
@@ -44,15 +88,426 @@ describe('captureAllSleepingAgentSessions', () => {
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
expect(
store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig
).toBeUndefined()
// Why: Windows update/reboot exits can miss beforeunload; the provider
// session handle must already be durable for pane-level cold restore.
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
agent: 'codex',
worktreeId: 'wt-1',
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{
agentType: 'codex',
tabId: 'tab-1',
leafId: 'leaf-1'
}
)
expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).not.toHaveProperty('launchConfig')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig).toEqual({
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
})
})
it('keeps private launch config through waiting and blocked live states', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
const launchToken = 'launch-token-1'
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5'",
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
const providerSession = { key: 'session_id' as const, id: 'codex-session-1' }
store.getState().registerAgentLaunchConfig('tab-1:leaf-1', launchConfig, {
agentType: 'codex',
launchToken,
tabId: 'tab-1',
providerSession: { key: 'session_id', id: 'codex-session-1' },
origin: 'live'
leafId: 'leaf-1'
})
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession, launchToken }
)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'waiting', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 20, stateStartedAt: 20 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession }
)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'blocked', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 30, stateStartedAt: 30 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession }
)
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']?.launchConfig).toEqual(
launchConfig
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig).toEqual(
launchConfig
)
})
it('uses provider session matching to capture launch config without a launch token', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
const launchToken = 'launch-token-1'
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5'",
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
const providerSession = { key: 'session_id' as const, id: 'codex-session-1' }
store.getState().registerAgentLaunchConfig('tab-1:leaf-1', launchConfig, {
agentType: 'codex',
launchToken,
tabId: 'tab-1',
leafId: 'leaf-1'
})
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession, launchToken }
)
const records = collectSleepingAgentSessionRecordsForWorktree(store.getState(), 'wt-1')
expect(records['tab-1:leaf-1']?.launchConfig).toEqual(launchConfig)
store.getState().captureAllSleepingAgentSessions()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
origin: 'quit',
launchConfig
})
})
it('preserves hydrated launch config during live recapture without a registry entry', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
const providerSession = { key: 'session_id' as const, id: 'codex-session-1' }
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5'",
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
store.setState({
sleepingAgentSessionsByPaneKey: {
'tab-1:leaf-1': {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession,
prompt: 'first task',
state: 'working',
capturedAt: 10,
updatedAt: 10,
launchConfig,
origin: 'live'
}
}
} as Partial<AppState>)
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 20, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession }
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig).toEqual(
launchConfig
)
expect(
collectSleepingAgentSessionRecordsForWorktree(store.getState(), 'wt-1')['tab-1:leaf-1']
?.launchConfig
).toEqual(launchConfig)
store.getState().captureAllSleepingAgentSessions()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
origin: 'quit',
launchConfig
})
})
it('does not reuse a stale pane launch config for a new provider session', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
const launchToken = 'launch-token-1'
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{
agentType: 'codex',
launchToken,
tabId: 'tab-1',
leafId: 'leaf-1'
}
)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'first task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' }, launchToken }
)
expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).not.toHaveProperty('launchConfig')
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'manual follow-up',
agentType: 'codex'
},
'Codex',
{ updatedAt: 20, stateStartedAt: 20 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-2' } }
)
expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.providerSession).toEqual({
key: 'session_id',
id: 'codex-session-2'
})
expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).not.toHaveProperty('launchConfig')
expect(
store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig
).toBeUndefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('does not attach a registered launch config to a different agent identity', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{ agentType: 'codex', tabId: 'tab-1', leafId: 'leaf-1' }
)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'manual follow-up',
agentType: 'claude'
},
'Claude',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'claude-session-1' } }
)
expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']).not.toHaveProperty('launchConfig')
})
it('clears a registered launch config for terminal reuse', () => {
const store = createTestStore()
store.getState().registerAgentLaunchConfig('tab-1:leaf-1', {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
})
store.getState().clearAgentLaunchConfig('tab-1:leaf-1')
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('drops a launch-config-only registry entry before the first hook status', () => {
const store = createTestStore()
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{ agentType: 'codex', launchToken: 'launch-token-1', tabId: 'tab-1', leafId: 'leaf-1' }
)
store.getState().dropAgentStatus('tab-1:leaf-1')
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('scrubs launch config registry entries when worktree agent status is dropped', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().registerAgentLaunchConfig(
'tab-1:leaf-1',
{
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
},
{ agentType: 'codex', tabId: 'tab-1', leafId: 'leaf-1' }
)
store.getState().dropAgentStatusByWorktree('wt-1')
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('clears launch config registry entries when sleeping sessions are cleared by worktree', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })],
'wt-2': [makeTab({ id: 'tab-2', worktreeId: 'wt-2' })]
}
} as Partial<AppState>)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
store
.getState()
.setAgentStatus(
'tab-2:leaf-2',
{ state: 'working', prompt: 'second task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-2', worktreeId: 'wt-2' },
{ providerSession: { key: 'session_id', id: 'codex-session-2' } }
)
store.getState().registerAgentLaunchConfig('tab-1:leaf-1', {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'first' }
})
store.getState().registerAgentLaunchConfig('tab-2:leaf-2', {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'second' }
})
store.getState().clearSleepingAgentSessionsByWorktree('wt-1')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-2:leaf-2']).toBeDefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-2:leaf-2']?.launchConfig).toEqual({
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'second' }
})
})
it('clears launch config registry entries when invalid sleeping sessions are pruned', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })],
'wt-2': [makeTab({ id: 'tab-2', worktreeId: 'wt-2' })]
}
} as Partial<AppState>)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
store
.getState()
.setAgentStatus(
'tab-2:leaf-2',
{ state: 'working', prompt: 'second task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-2', worktreeId: 'wt-2' },
{ providerSession: { key: 'session_id', id: 'codex-session-2' } }
)
store.getState().registerAgentLaunchConfig('tab-1:leaf-1', {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'first' }
})
store.getState().registerAgentLaunchConfig('tab-2:leaf-2', {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'second' }
})
store.getState().pruneSleepingAgentSessions(new Set(['wt-2']))
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-2:leaf-2']).toBeDefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-2:leaf-2']?.launchConfig).toEqual({
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'second' }
})
})
@@ -128,6 +583,61 @@ describe('captureAllSleepingAgentSessions', () => {
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('does not reuse launch config from a completed same-pane agent', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
const launchToken = 'launch-token-1'
store.getState().setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{
providerSession: { key: 'session_id', id: 'codex-session-1' },
launchToken,
launchConfig: {
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
}
)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'done', prompt: 'first task', agentType: 'codex' },
'Codex',
{ updatedAt: 20, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' }, launchToken }
)
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state: 'working', prompt: 'manual task', agentType: 'codex' },
'Codex',
{ updatedAt: 30, stateStartedAt: 30 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ launchToken }
)
const entry = store.getState().agentStatusByPaneKey['tab-1:leaf-1']
expect(entry?.providerSession).toBeUndefined()
expect(entry).not.toHaveProperty('launchConfig')
expect(
store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.launchConfig
).toBeUndefined()
})
it('captures resumable agents across every worktree, not just one', () => {
+488 -19
View File
@@ -15,6 +15,7 @@ import {
getAgentResumeArgv,
isResumableTuiAgent,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import {
@@ -56,6 +57,21 @@ type DropHibernatedAgentPaneOptions = {
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
type AgentLaunchConfigRegistrationMetadata = {
agentType?: AgentType
launchToken?: string
tabId?: string
leafId?: string
terminalHandle?: string
providerSession?: AgentProviderSessionMetadata
}
type AgentLaunchConfigRegistryEntry = {
launchConfig: SleepingAgentLaunchConfig
registeredAt: number
identity: AgentLaunchConfigRegistrationMetadata
}
export type AgentStatusSlice = {
/** Explicit agent status entries keyed by `${tabId}:${leafId}` composite.
* Real-time only lives in renderer memory, not persisted to disk. */
@@ -79,6 +95,10 @@ export type AgentStatusSlice = {
* live status rows; they power the one-click CLI resume action on wake. */
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
/** Ephemeral launch snapshots keyed by concrete pane. Hook payloads do not
* carry Orca launch settings, so the renderer supplies them from startup. */
agentLaunchConfigByPaneKey: Record<string, AgentLaunchConfigRegistryEntry>
/** Pane keys explicitly torn down (pane close, tab close, PTY exit, manual
* dismissal) and therefore forbidden from being re-retained on their next
* disappearance. Consumed by the retention sync as a one-shot suppressor. */
@@ -91,9 +111,23 @@ export type AgentStatusSlice = {
terminalTitle?: string,
timing?: { updatedAt?: number; stateStartedAt?: number },
routing?: { tabId?: string; worktreeId?: string; terminalHandle?: string },
metadata?: { providerSession?: AgentProviderSessionMetadata }
metadata?: {
providerSession?: AgentProviderSessionMetadata
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
}
) => void
registerAgentLaunchConfig: (
paneKey: string,
launchConfig: SleepingAgentLaunchConfig,
metadata?: AgentLaunchConfigRegistrationMetadata
) => void
getAgentLaunchConfigForStatusEntry: (
entry: AgentStatusEntry
) => SleepingAgentLaunchConfig | undefined
clearAgentLaunchConfig: (paneKey: string) => void
setRuntimeAgentOrchestrationByPaneKey: (
entries: Record<string, AgentStatusOrchestrationContext>
) => void
@@ -184,6 +218,15 @@ function getTabIdFromPaneKey(paneKey: string): string | null {
return paneKey.slice(0, separator)
}
function getLeafIdFromPaneKey(paneKey: string): string | null {
const separator = paneKey.indexOf(':')
if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) {
return null
}
const leafId = paneKey.slice(separator + 1)
return leafId.length > 0 ? leafId : null
}
function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null {
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId) {
@@ -273,6 +316,7 @@ function sleepingRecordFromEntry(args: {
worktreeId: string
tab?: TerminalTab
capturedAt: number
launchConfig?: SleepingAgentLaunchConfig
origin?: SleepingAgentSessionRecord['origin']
}): SleepingAgentSessionRecord | null {
const agent = args.entry.agentType
@@ -299,6 +343,7 @@ function sleepingRecordFromEntry(args: {
...(args.entry.lastAssistantMessage
? { lastAssistantMessage: args.entry.lastAssistantMessage }
: {}),
...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}),
...(args.origin ? { origin: args.origin } : {})
}
}
@@ -325,7 +370,8 @@ export function collectSleepingAgentSessionRecordsForWorktree(
entry: retained.entry,
worktreeId,
tab: retained.tab,
capturedAt
capturedAt,
launchConfig: getLaunchConfigForEntry(state, retained.entry)
})
if (record) {
records[record.paneKey] = record
@@ -345,7 +391,8 @@ export function collectSleepingAgentSessionRecordsForWorktree(
state,
entry,
worktreeId,
capturedAt
capturedAt,
launchConfig: getLaunchConfigForEntry(state, entry)
})
if (record) {
records[record.paneKey] = record
@@ -399,10 +446,156 @@ function recoveryRecordMatches(
existing.worktreeId === next.worktreeId &&
existing.tabId === next.tabId &&
existing.providerSession.key === next.providerSession.key &&
existing.providerSession.id === next.providerSession.id
existing.providerSession.id === next.providerSession.id &&
launchConfigsEqual(existing.launchConfig, next.launchConfig)
)
}
function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig {
return {
...(config.agentCommand ? { agentCommand: config.agentCommand } : {}),
agentArgs: config.agentArgs,
agentEnv: { ...config.agentEnv }
}
}
function launchConfigsEqual(
a: SleepingAgentLaunchConfig | undefined,
b: SleepingAgentLaunchConfig | undefined
): boolean {
if (a === undefined || b === undefined) {
return a === b
}
if (a.agentCommand !== b.agentCommand || a.agentArgs !== b.agentArgs) {
return false
}
const aKeys = Object.keys(a.agentEnv)
const bKeys = Object.keys(b.agentEnv)
return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key])
}
function providerSessionsEqual(
a: AgentProviderSessionMetadata | undefined,
b: AgentProviderSessionMetadata | undefined
): boolean {
if (a === undefined || b === undefined) {
return a === b
}
return a.key === b.key && a.id === b.id
}
function normalizeLaunchConfigRegistrationMetadata(
paneKey: string,
metadata: AgentLaunchConfigRegistrationMetadata | undefined
): AgentLaunchConfigRegistrationMetadata {
return {
...(metadata?.agentType ? { agentType: metadata.agentType } : {}),
...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}),
tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined,
...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}),
...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {})
}
}
function launchConfigRegistryEntriesEqual(
a: AgentLaunchConfigRegistryEntry | undefined,
b: AgentLaunchConfigRegistryEntry
): boolean {
return (
a !== undefined &&
launchConfigsEqual(a.launchConfig, b.launchConfig) &&
a.identity.agentType === b.identity.agentType &&
a.identity.launchToken === b.identity.launchToken &&
a.identity.tabId === b.identity.tabId &&
a.identity.leafId === b.identity.leafId &&
a.identity.terminalHandle === b.identity.terminalHandle &&
providerSessionsEqual(a.identity.providerSession, b.identity.providerSession)
)
}
function registryEntryMatchesStatus(args: {
entry: AgentLaunchConfigRegistryEntry | undefined
paneKey: string
agentType: AgentType | undefined
tabId: string | undefined
terminalHandle: string | undefined
launchToken: string | undefined
providerSession: AgentProviderSessionMetadata | undefined
existingProviderSession: AgentProviderSessionMetadata | undefined
providerSessionChanged: boolean
}): boolean {
const entry = args.entry
if (!entry || args.providerSessionChanged) {
return false
}
const identity = entry.identity
if (identity.agentType !== undefined && identity.agentType !== args.agentType) {
return false
}
if (identity.tabId !== undefined && identity.tabId !== args.tabId) {
return false
}
if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) {
return false
}
if (
identity.terminalHandle !== undefined &&
(args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle)
) {
return false
}
if (identity.providerSession !== undefined) {
return providerSessionsEqual(identity.providerSession, args.providerSession)
}
if (
identity.launchToken !== undefined &&
(args.launchToken === undefined || identity.launchToken !== args.launchToken)
) {
return false
}
if (identity.launchToken !== undefined) {
return true
}
if (identity.terminalHandle !== undefined) {
return true
}
if (args.existingProviderSession && args.providerSession) {
return providerSessionsEqual(args.existingProviderSession, args.providerSession)
}
return false
}
function getLaunchConfigForEntry(
state: AppState,
entry: AgentStatusEntry
): SleepingAgentLaunchConfig | undefined {
const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey]
const registryLaunchConfig = registryEntryMatchesStatus({
entry: registryEntry,
paneKey: entry.paneKey,
agentType: entry.agentType,
tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined,
terminalHandle: entry.terminalHandle,
launchToken: undefined,
providerSession: entry.providerSession,
existingProviderSession: entry.providerSession,
providerSessionChanged: false
})
? registryEntry?.launchConfig
: undefined
if (registryLaunchConfig) {
return registryLaunchConfig
}
const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey]
return sleepingRecord?.launchConfig &&
sleepingRecord.agent === entry.agentType &&
entry.providerSession &&
providerSessionsEqual(sleepingRecord.providerSession, entry.providerSession)
? sleepingRecord.launchConfig
: undefined
}
function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@@ -494,6 +687,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
agentStatusEpoch: 0,
retainedAgentsByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
agentLaunchConfigByPaneKey: {},
retentionSuppressedPaneKeys: {},
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
@@ -555,6 +749,89 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
})
},
registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => {
set((s) => {
const copiedLaunchConfig = copyLaunchConfig(launchConfig)
const nextRegistryEntry: AgentLaunchConfigRegistryEntry = {
launchConfig: copiedLaunchConfig,
registeredAt: Date.now(),
identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata)
}
const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey]
const registryChanged = !launchConfigRegistryEntriesEqual(
existingRegistryEntry,
nextRegistryEntry
)
const existingEntry = s.agentStatusByPaneKey[paneKey]
const entryMatchesRegistry = registryEntryMatchesStatus({
entry: nextRegistryEntry,
paneKey,
agentType: existingEntry?.agentType,
tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
terminalHandle: existingEntry?.terminalHandle,
launchToken: metadata?.launchToken,
providerSession: existingEntry?.providerSession,
existingProviderSession: existingEntry?.providerSession,
providerSessionChanged: false
})
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey
if (existingSleepingRecord && entryMatchesRegistry && existingEntry) {
const worktreeId =
existingEntry.worktreeId ??
existingSleepingRecord.worktreeId ??
findAgentPaneWorktreeId(s, paneKey)
const refreshedRecord = worktreeId
? sleepingRecordFromEntry({
state: s,
entry: existingEntry,
worktreeId,
capturedAt: existingSleepingRecord.capturedAt,
launchConfig: copiedLaunchConfig,
origin: existingSleepingRecord.origin
})
: null
if (refreshedRecord) {
nextSleepingAgentSessions = {
...s.sleepingAgentSessionsByPaneKey,
[paneKey]: {
...refreshedRecord,
capturedAt: existingSleepingRecord.capturedAt
}
}
}
}
if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) {
return s
}
return {
...(registryChanged
? {
agentLaunchConfigByPaneKey: {
...s.agentLaunchConfigByPaneKey,
[paneKey]: nextRegistryEntry
}
}
: {}),
...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey
? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions }
: {})
}
})
},
getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry),
clearAgentLaunchConfig: (paneKey) => {
set((s) => {
if (!(paneKey in s.agentLaunchConfigByPaneKey)) {
return s
}
const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }
delete nextLaunchConfigs[paneKey]
return { agentLaunchConfigByPaneKey: nextLaunchConfigs }
})
},
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => {
const updatedAt = timing?.updatedAt ?? Date.now()
let completionRefreshWorktreeId: string | null = null
@@ -650,9 +927,55 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
payload.state === 'done' ? existing?.orchestration : undefined
const orchestration =
payloadMergedOrchestration ?? runtimeMergedOrchestration ?? completedFallbackOrchestration
const canReuseExistingIdentity =
existing?.agentType === identity.agentType &&
!isAgentCompletionState(existing.state) &&
!isAgentCompletionState(payload.state)
const providerSession =
metadata?.providerSession ??
(existing?.agentType === identity.agentType ? existing.providerSession : undefined)
(canReuseExistingIdentity ? existing.providerSession : undefined)
const existingProviderSession = canReuseExistingIdentity
? existing.providerSession
: undefined
const providerSessionChanged =
Boolean(metadata?.providerSession && existingProviderSession) &&
(metadata?.providerSession?.key !== existingProviderSession?.key ||
metadata?.providerSession?.id !== existingProviderSession?.id)
const statusTabId =
routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined
const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle
const registryEntry = s.agentLaunchConfigByPaneKey[paneKey]
const matchedRegistryLaunchConfig = registryEntryMatchesStatus({
entry: registryEntry,
paneKey,
agentType: identity.agentType,
tabId: statusTabId,
terminalHandle: statusTerminalHandle,
launchToken: metadata?.launchToken,
providerSession,
existingProviderSession,
providerSessionChanged
})
? registryEntry?.launchConfig
: undefined
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
const matchedSleepingLaunchConfig =
payload.state !== 'done' &&
existingSleepingRecord?.launchConfig &&
existingSleepingRecord.agent === identity.agentType &&
providerSession &&
providerSessionsEqual(existingSleepingRecord.providerSession, providerSession)
? existingSleepingRecord.launchConfig
: undefined
// Why: pane keys can be reused after a manually-started agent replaces
// an Orca-launched one. Once the provider session changes, the old
// pane-key launch registry must not bleed options into the new session.
const launchConfigSource =
(payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken
? metadata?.launchConfig
: undefined) ??
matchedRegistryLaunchConfig ??
matchedSleepingLaunchConfig
const entry: AgentStatusEntry = {
state: payload.state,
prompt: payload.prompt,
@@ -660,13 +983,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
stateStartedAt,
agentType: identity.agentType,
paneKey,
terminalHandle: routing?.terminalHandle ?? existing?.terminalHandle,
terminalHandle: statusTerminalHandle,
worktreeId:
routing?.worktreeId ??
existing?.worktreeId ??
findAgentPaneWorktreeId(s, paneKey) ??
undefined,
tabId: routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
tabId: statusTabId,
terminalTitle: effectiveTitle,
stateHistory: history,
toolName: payload.toolName,
@@ -742,7 +1065,6 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
const liveRecoveryWorktreeId =
entry.state === 'done'
? null
@@ -753,10 +1075,38 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry,
worktreeId: liveRecoveryWorktreeId,
capturedAt: updatedAt,
launchConfig: launchConfigSource,
origin: 'live'
})
: null
let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey
let nextLaunchConfigs = s.agentLaunchConfigByPaneKey
if (
matchedRegistryLaunchConfig &&
registryEntry &&
providerSession &&
!providerSessionsEqual(registryEntry.identity.providerSession, providerSession)
) {
nextLaunchConfigs = {
...nextLaunchConfigs,
[paneKey]: {
...registryEntry,
identity: {
...registryEntry.identity,
providerSession
}
}
}
}
// Why: launch tokens can remain in a shell after an Orca-started TUI exits;
// once the original session is done they must no longer authorize config reuse.
if (
(providerSessionChanged || entry.state === 'done') &&
paneKey in s.agentLaunchConfigByPaneKey
) {
nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }
delete nextLaunchConfigs[paneKey]
}
if (liveRecoveryRecord) {
if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) {
nextSleepingAgentSessions = {
@@ -771,6 +1121,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry },
sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
agentStatusEpoch:
@@ -831,6 +1182,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
removeAgentStatus: (paneKey) => {
if (
!(paneKey in get().agentStatusByPaneKey) &&
!(paneKey in get().agentLaunchConfigByPaneKey) &&
!Object.values(get().migrationUnsupportedByPtyId).some((entry) => entry.paneKey === paneKey)
) {
return
@@ -841,6 +1193,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (hasLive) {
delete next[paneKey]
}
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
@@ -860,6 +1219,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// as setAgentStatus.
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
@@ -875,10 +1235,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const prefix = `${tabIdPrefix}:`
const currentKeys = Object.keys(get().agentStatusByPaneKey)
const toRemove = currentKeys.filter((k) => k.startsWith(prefix))
const launchConfigKeys = Object.keys(get().agentLaunchConfigByPaneKey).filter((k) =>
k.startsWith(prefix)
)
const hasMigrationUnsupported = Object.values(get().migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey?.startsWith(prefix)
)
if (toRemove.length === 0 && !hasMigrationUnsupported) {
if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) {
return
}
set((s) => {
@@ -886,6 +1249,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
for (const key of toRemove) {
delete next[key]
}
const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey?.startsWith(prefix) ?? false
@@ -905,6 +1272,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// no keys matched the prefix.
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
@@ -938,14 +1306,29 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
nextAck = { ...nextAck }
delete nextAck[paneKey]
}
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
// Why: bail when there is genuinely nothing to do. The old guard
// `!hasLive && !hasRetained && alreadySuppressed` leaked a phantom
// suppressor write in the `!hasLive && !hasRetained && !alreadySuppressed`
// case. With the hasLive-gated suppressor below, a no-op drop on a
// paneKey with no live and no retained entry truly has nothing to
// change, so short-circuit here — but still flush a pending ack
// cleanup if one is present.
// cleanup or launch-config cleanup if one is present.
if (!hasLive && !hasRetained && !migrationUnsupported.changed) {
if (hasLaunchConfig) {
return {
agentLaunchConfigByPaneKey: nextLaunchConfigs,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {})
}
}
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@@ -956,7 +1339,6 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (hasLive) {
delete nextLive[paneKey]
}
const nextRetained = hasRetained
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
@@ -996,6 +1378,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
@@ -1043,6 +1426,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
let hadLive = false
set((s) => {
const liveKeys = Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix))
const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter((k) =>
k.startsWith(prefix)
)
const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter((k) =>
k.startsWith(prefix)
)
@@ -1061,7 +1447,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
delete nextAck[k]
}
}
if (liveKeys.length === 0 && retainedKeys.length === 0 && !migrationUnsupported.changed) {
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
retainedKeys.length === 0 &&
!migrationUnsupported.changed
) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@@ -1074,6 +1465,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
for (const key of liveKeys) {
delete nextLive[key]
}
const nextLaunchConfigs =
launchConfigKeys.length > 0
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const nextRetained =
retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey
@@ -1106,6 +1504,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
@@ -1131,6 +1530,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const liveEntry = s.agentStatusByPaneKey[paneKey]
const hasLive = liveEntry !== undefined
const hasRetained = paneKey in s.retainedAgentsByPaneKey
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
@@ -1161,7 +1561,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
nextAck = { ...nextAck }
delete nextAck[paneKey]
}
if (!hasLive && !hasRetained && !migrationUnsupported.changed && !keepsCompletionEvidence) {
if (
!hasLive &&
!hasRetained &&
!hasLaunchConfig &&
!migrationUnsupported.changed &&
!keepsCompletionEvidence
) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
}
@@ -1173,6 +1579,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (hasLive) {
delete nextLive[paneKey]
}
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
const nextRetained =
hasRetained || keepsCompletionEvidence
@@ -1192,6 +1604,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
@@ -1225,6 +1638,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
)
const liveKeys = liveEntries.map(([paneKey]) => paneKey)
const liveKeySet = new Set(liveKeys)
const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter(
(paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey)
)
const retainedKeys = Object.entries(s.retainedAgentsByPaneKey)
.filter(
([paneKey, retained]) =>
@@ -1295,6 +1711,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// subscribers for nothing.
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
retainedKeys.length === 0 &&
retainedEvidence.size === 0 &&
!migrationUnsupported.changed
@@ -1311,6 +1728,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
for (const key of liveKeys) {
delete nextLive[key]
}
const nextLaunchConfigs =
launchConfigKeys.length > 0
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const nextRetained =
retainedKeys.length > 0 || retainedEvidence.size > 0
@@ -1344,6 +1768,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
@@ -1404,6 +1829,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
entry,
worktreeId,
capturedAt,
launchConfig: getLaunchConfigForEntry(s, entry),
origin: 'quit'
})
if (record && next[record.paneKey] !== record) {
@@ -1417,12 +1843,27 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
clearSleepingAgentSession: (paneKey) => {
set((s) => {
if (!(paneKey in s.sleepingAgentSessionsByPaneKey)) {
const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
if (!hasSleepingRecord && !hasLaunchConfig) {
return s
}
const next = { ...s.sleepingAgentSessionsByPaneKey }
delete next[paneKey]
return { sleepingAgentSessionsByPaneKey: next }
const nextSleeping = hasSleepingRecord
? { ...s.sleepingAgentSessionsByPaneKey }
: s.sleepingAgentSessionsByPaneKey
if (hasSleepingRecord) {
delete nextSleeping[paneKey]
}
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
return {
sleepingAgentSessionsByPaneKey: nextSleeping,
agentLaunchConfigByPaneKey: nextLaunchConfigs
}
})
},
@@ -1430,14 +1871,28 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
set((s) => {
let changed = false
const next: Record<string, SleepingAgentSessionRecord> = {}
const launchConfigKeysToRemove: string[] = []
for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) {
if (record.worktreeId === worktreeId) {
changed = true
launchConfigKeysToRemove.push(paneKey)
continue
}
next[paneKey] = record
}
return changed ? { sleepingAgentSessionsByPaneKey: next } : s
const nextLaunchConfigs =
launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null
if (nextLaunchConfigs) {
for (const paneKey of launchConfigKeysToRemove) {
delete nextLaunchConfigs[paneKey]
}
}
return changed
? {
sleepingAgentSessionsByPaneKey: next,
...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {})
}
: s
})
},
@@ -1445,14 +1900,28 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
set((s) => {
let changed = false
const next: Record<string, SleepingAgentSessionRecord> = {}
const launchConfigKeysToRemove: string[] = []
for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) {
if (!validWorktreeIds.has(record.worktreeId)) {
changed = true
launchConfigKeysToRemove.push(paneKey)
continue
}
next[paneKey] = record
}
return changed ? { sleepingAgentSessionsByPaneKey: next } : s
const nextLaunchConfigs =
launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null
if (nextLaunchConfigs) {
for (const paneKey of launchConfigKeysToRemove) {
delete nextLaunchConfigs[paneKey]
}
}
return changed
? {
sleepingAgentSessionsByPaneKey: next,
...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {})
}
: s
})
},
@@ -60,6 +60,12 @@ describe('repo slice skipped-onboarding folder startup', () => {
startup: {
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
telemetry: {
agent_kind: 'codex',
launch_source: 'onboarding',
+19 -1
View File
@@ -11,6 +11,7 @@ import type {
WorkspaceKey,
WorkspaceSessionState
} from '../../../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
folderWorkspaceKey,
@@ -295,6 +296,9 @@ export type TerminalSlice = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
/** Initial prompt-start status for agents that lack native prompt hooks. */
initialAgentStatus?: { agent: TuiAgent; prompt: string }
/** Show the restored-session banner when this startup command mounts. */
@@ -447,6 +451,9 @@ export type TerminalSlice = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
@@ -457,6 +464,9 @@ export type TerminalSlice = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
@@ -2328,10 +2338,18 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
},
queueTabStartupCommand: (tabId, startup) => {
// Why: launchToken is only meaningful for tracked launch-config reuse;
// plain startup commands must not mint or carry a synthetic token.
const launchToken = startup.launchConfig
? (startup.launchToken ?? createBrowserUuid())
: undefined
set((s) => ({
pendingStartupByTabId: {
...s.pendingStartupByTabId,
[tabId]: startup
[tabId]: {
...startup,
...(launchToken ? { launchToken } : {})
}
}
}))
},
@@ -2532,7 +2532,12 @@ describe('worktree remote runtime mutations', () => {
undefined,
{
command: "codex 'summarize repo'",
env: { ORCA_AGENT_MODE: 'direct' }
env: { ORCA_AGENT_MODE: 'direct' },
launchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
}
}
)
@@ -2548,6 +2553,11 @@ describe('worktree remote runtime mutations', () => {
createdWithAgent: 'codex',
startupCommand: "codex 'summarize repo'",
startupEnv: { ORCA_AGENT_MODE: 'direct' },
startupLaunchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
},
activate: true
})
})
@@ -1836,6 +1836,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? {
startupCommand: startup.command,
...(startup.env ? { startupEnv: startup.env } : {}),
...(startup.launchConfig
? { startupLaunchConfig: startup.launchConfig }
: {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {}),
+23 -2
View File
@@ -1606,7 +1606,18 @@ describe('web worktree preload API', () => {
name: 'review-pr-42',
baseBranch: TEST_COMMIT_OID,
compareBaseRef: 'refs/remotes/origin/main',
setupDecision: 'inherit'
setupDecision: 'inherit',
createdWithAgent: 'codex',
startup: {
command: "codex 'summarize repo'",
env: { ORCA_AGENT_MODE: 'direct' },
launchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
},
startupCommandDelivery: 'shell-ready'
}
})
await globals.window.api.worktrees.resolvePrBase({
repoId: 'repo-1',
@@ -1629,7 +1640,17 @@ describe('web worktree preload API', () => {
params: expect.objectContaining({
repo: 'repo-1',
baseBranch: TEST_COMMIT_OID,
compareBaseRef: 'refs/remotes/origin/main'
compareBaseRef: 'refs/remotes/origin/main',
createdWithAgent: 'codex',
startupCommand: "codex 'summarize repo'",
startupEnv: { ORCA_AGENT_MODE: 'direct' },
startupLaunchConfig: {
agentCommand: 'codex',
agentArgs: '--model gpt-5',
agentEnv: { ORCA_AGENT_MODE: 'direct' }
},
startupCommandDelivery: 'shell-ready',
activate: true
})
},
{
+13
View File
@@ -1159,6 +1159,19 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
setupDecision: args.setupDecision,
createdWithAgent: args.createdWithAgent,
pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename,
...(args.startup
? {
startupCommand: args.startup.command,
...(args.startup.env ? { startupEnv: args.startup.env } : {}),
...(args.startup.launchConfig
? { startupLaunchConfig: args.startup.launchConfig }
: {}),
...(args.startup.startupCommandDelivery
? { startupCommandDelivery: args.startup.startupCommandDelivery }
: {}),
activate: true
}
: {}),
parentWorkspace: args.parentWorkspace,
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder,
+4
View File
@@ -173,6 +173,8 @@ export function warnOnHookEnvOrVersionMismatch(
export type AgentHookEventPayload = {
paneKey: string
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
@@ -3012,6 +3014,7 @@ export function normalizeHookPayload(
return null
}
const worktreeId = readStringField(record, 'worktreeId')
const launchToken = readStringField(record, 'launchToken')
const hookPayloadRecord = hookPayload as Record<string, unknown>
let promptInteractionKey: string | undefined
@@ -3132,6 +3135,7 @@ export function normalizeHookPayload(
return payload
? {
paneKey,
launchToken,
tabId,
worktreeId,
connectionId: null,
+2
View File
@@ -57,6 +57,8 @@ export const REMOTE_AGENT_HOOK_ENV = 'remote' as const
export type AgentHookRelayEnvelope = {
source: AgentHookSource
paneKey: string
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
tabId?: string
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
+7
View File
@@ -22,6 +22,12 @@ export type AgentProviderSessionMetadata = {
id: string
}
export type SleepingAgentLaunchConfig = {
agentCommand?: string
agentArgs: string
agentEnv: Record<string, string>
}
export type SleepingAgentSessionRecord = {
paneKey: string
tabId?: string
@@ -35,6 +41,7 @@ export type SleepingAgentSessionRecord = {
terminalTitle?: string
lastAssistantMessage?: string
connectionId?: string | null
launchConfig?: SleepingAgentLaunchConfig
/** How the record was captured. Worktree-sleep records (legacy records have
* no origin) are consumed by worktree activation, which opens a fresh tab.
* Quit/live records describe panes that still exist in the restored session,
+1
View File
@@ -168,6 +168,7 @@ export type ParsedAgentStatusPayload = Omit<AgentStatusPayload, 'prompt'> & { pr
*/
export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
paneKey: string
launchToken?: string
terminalHandle?: string
tabId?: string
worktreeId?: string
+65
View File
@@ -0,0 +1,65 @@
import { tokenizeCustomCommandTemplate } from './commit-message-prompt'
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
export function resolveStartupShell(
platform: NodeJS.Platform,
shell?: AgentStartupShell
): AgentStartupShell {
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
}
export function quoteStartupArg(value: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `'${value.replace(/'/g, "''")}'`
}
if (shell === 'cmd') {
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function buildShellCommandFromArgv(
args: readonly string[],
shell: AgentStartupShell
): string {
const command = args.map((arg) => quoteStartupArg(arg, shell)).join(' ')
if (shell === 'powershell' && command) {
return `& ${command}`
}
return command
}
export function clearEnvCommand(name: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `Remove-Item Env:${name} -ErrorAction SilentlyContinue`
}
if (shell === 'cmd') {
return `set "${name}="`
}
return `unset ${name}`
}
export function commandSeparator(shell: AgentStartupShell): string {
return shell === 'cmd' ? ' & ' : '; '
}
export type AgentCliArgsPlan = { ok: true; suffix: string } | { ok: false; error: string }
export function planAgentCliArgsSuffix(
agentArgs: string | null | undefined,
shell: AgentStartupShell
): AgentCliArgsPlan {
const trimmed = agentArgs?.trim()
if (!trimmed) {
return { ok: true, suffix: '' }
}
const tokenized = tokenizeCustomCommandTemplate(trimmed)
if (!tokenized.ok) {
return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` }
}
return {
ok: true,
suffix: tokenized.tokens.map((token) => quoteStartupArg(token, shell)).join(' ')
}
}
+65 -4
View File
@@ -73,7 +73,8 @@ describe('tui agent startup plans', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null
followupPrompt: null,
launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }
})
})
@@ -113,7 +114,8 @@ describe('tui agent startup plans', () => {
agent: 'openclaude',
launchCommand: "openclaude 'fix it'",
expectedProcess: 'openclaude',
followupPrompt: null
followupPrompt: null,
launchConfig: { agentCommand: 'openclaude', agentArgs: '', agentEnv: {} }
})
})
@@ -129,7 +131,8 @@ describe('tui agent startup plans', () => {
agent: 'mistral-vibe',
launchCommand: 'vibe',
expectedProcess: 'vibe',
followupPrompt: 'fix it'
followupPrompt: 'fix it',
launchConfig: { agentCommand: 'vibe', agentArgs: '', agentEnv: {} }
})
})
@@ -177,6 +180,23 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toBe("codex --profile work 'resume' 's1'")
})
it('uses a captured launch command when building resume plans after overrides change', () => {
const plan = buildAgentResumeStartupPlan({
agent: 'codex',
providerSession: { key: 'session_id', id: 's1' },
cmdOverrides: { codex: 'codex --profile changed' },
agentCommand: 'codex --profile captured',
platform: 'linux'
})
expect(plan?.launchCommand).toBe("codex --profile captured 'resume' 's1'")
expect(plan?.launchConfig).toEqual({
agentCommand: 'codex --profile captured',
agentArgs: '',
agentEnv: {}
})
})
it('appends shell-quoted CLI arguments before prompt delivery flags', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
@@ -215,6 +235,25 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toBe('goose')
expect(plan?.env).toEqual({ GOOSE_MODE: 'auto' })
expect(plan?.launchConfig).toEqual({
agentCommand: 'goose',
agentArgs: '',
agentEnv: { GOOSE_MODE: 'auto' }
})
})
it('captures empty args and env as explicit launch config values', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: '',
cmdOverrides: {},
agentArgs: '',
agentEnv: {},
platform: 'linux',
allowEmptyPromptLaunch: true
})
expect(plan?.launchConfig).toEqual({ agentCommand: 'claude', agentArgs: '', agentEnv: {} })
})
it('does not append the unsupported OpenCode TUI skip-permissions arg', () => {
@@ -330,7 +369,29 @@ describe('tui agent startup plans', () => {
agent: 'devin',
launchCommand: "devin '--permission-mode' 'bypass'",
expectedProcess: 'devin',
followupPrompt: 'fix the tests'
followupPrompt: 'fix the tests',
launchConfig: {
agentCommand: "devin '--permission-mode' 'bypass'",
agentArgs: '--permission-mode bypass',
agentEnv: {}
}
})
})
it('excludes transient draft prompt env from launch config', () => {
const plan = buildAgentDraftLaunchPlan({
agent: 'pi',
draft: 'prefill text',
cmdOverrides: {},
agentEnv: { ORCA_AGENT_MODE: 'managed' },
platform: 'linux'
})
expect(plan?.env).toEqual({ ORCA_AGENT_MODE: 'managed', ORCA_PI_PREFILL: 'prefill text' })
expect(plan?.launchConfig).toEqual({
agentCommand: 'pi',
agentArgs: '',
agentEnv: { ORCA_AGENT_MODE: 'managed' }
})
})
+65 -73
View File
@@ -2,9 +2,17 @@ import { isShellProcess } from './agent-detection'
import {
getAgentResumeArgv,
type AgentProviderSessionMetadata,
type ResumableTuiAgent
type ResumableTuiAgent,
type SleepingAgentLaunchConfig
} from './agent-session-resume'
import { tokenizeCustomCommandTemplate } from './commit-message-prompt'
import {
clearEnvCommand,
commandSeparator,
planAgentCliArgsSuffix,
quoteStartupArg,
resolveStartupShell,
type AgentStartupShell
} from './tui-agent-startup-shell'
import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config'
import type { StartupCommandDelivery } from './codex-startup-delivery'
import type { TuiAgent } from './types'
@@ -16,75 +24,12 @@ export type AgentStartupPlan = {
launchCommand: string
expectedProcess: string
followupPrompt: string | null
launchConfig: SleepingAgentLaunchConfig
draftPrompt?: string | null
env?: Record<string, string>
startupCommandDelivery?: StartupCommandDelivery
}
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
export function resolveStartupShell(
platform: NodeJS.Platform,
shell?: AgentStartupShell
): AgentStartupShell {
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
}
export function quoteStartupArg(value: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `'${value.replace(/'/g, "''")}'`
}
if (shell === 'cmd') {
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function buildShellCommandFromArgv(
args: readonly string[],
shell: AgentStartupShell
): string {
const command = args.map((arg) => quoteStartupArg(arg, shell)).join(' ')
if (shell === 'powershell' && command) {
return `& ${command}`
}
return command
}
function clearEnvCommand(name: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `Remove-Item Env:${name} -ErrorAction SilentlyContinue`
}
if (shell === 'cmd') {
return `set "${name}="`
}
return `unset ${name}`
}
function commandSeparator(shell: AgentStartupShell): string {
return shell === 'cmd' ? ' & ' : '; '
}
export type AgentCliArgsPlan = { ok: true; suffix: string } | { ok: false; error: string }
export function planAgentCliArgsSuffix(
agentArgs: string | null | undefined,
shell: AgentStartupShell
): AgentCliArgsPlan {
const trimmed = agentArgs?.trim()
if (!trimmed) {
return { ok: true, suffix: '' }
}
const tokenized = tokenizeCustomCommandTemplate(trimmed)
if (!tokenized.ok) {
return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` }
}
return {
ok: true,
suffix: tokenized.tokens.map((token) => quoteStartupArg(token, shell)).join(' ')
}
}
function resolveBaseCommand(args: {
agent: TuiAgent
cmdOverrides: Partial<Record<TuiAgent, string>>
@@ -103,6 +48,20 @@ function resolveBaseCommand(args: {
return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command }
}
function buildSleepingAgentLaunchConfig(args: {
agentCommand?: string | null
agentArgs?: string | null
agentEnv?: Record<string, string> | null
}): SleepingAgentLaunchConfig {
return {
...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}),
agentArgs: args.agentArgs ?? '',
// Why: startupPlan.env may include prompt transport or pane identity env; the
// durable resume snapshot is limited to Orca-managed agent env inputs.
agentEnv: args.agentEnv ? { ...args.agentEnv } : {}
}
}
export function buildAgentStartupPlan(args: {
agent: TuiAgent
prompt: string
@@ -127,6 +86,10 @@ export function buildAgentStartupPlan(args: {
if (!baseCommand.ok) {
return null
}
const launchConfig = buildSleepingAgentLaunchConfig({
...args,
agentCommand: baseCommand.command
})
if (!trimmedPrompt) {
if (!allowEmptyPromptLaunch) {
@@ -137,6 +100,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: baseCommand.command,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -149,6 +113,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: `${baseCommand.command} ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}),
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
@@ -160,6 +125,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: `${baseCommand.command} --prompt ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -170,6 +136,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: `${baseCommand.command} --prompt-interactive ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -180,6 +147,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: `${baseCommand.command} -i ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -189,6 +157,7 @@ export function buildAgentStartupPlan(args: {
launchCommand: baseCommand.command,
expectedProcess: config.expectedProcess,
followupPrompt: trimmedPrompt,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -201,6 +170,7 @@ export function buildAgentResumeStartupPlan(args: {
shell?: AgentStartupShell
agentArgs?: string | null
agentEnv?: Record<string, string> | null
agentCommand?: string | null
}): AgentStartupPlan | null {
const argv = getAgentResumeArgv(args.agent, args.providerSession)
if (!argv) {
@@ -208,16 +178,23 @@ export function buildAgentResumeStartupPlan(args: {
}
const shell = resolveStartupShell(args.platform, args.shell)
const config = TUI_AGENT_CONFIG[args.agent]
const baseCommand = resolveBaseCommand({
agent: args.agent,
cmdOverrides: args.cmdOverrides,
platform: args.platform,
shell,
agentArgs: args.agentArgs
})
const resolvedAgentCommand = args.agentCommand?.trim()
const baseCommand = resolvedAgentCommand
? ({ ok: true, command: resolvedAgentCommand } as const)
: resolveBaseCommand({
agent: args.agent,
cmdOverrides: args.cmdOverrides,
platform: args.platform,
shell,
agentArgs: args.agentArgs
})
if (!baseCommand.ok) {
return null
}
const launchConfig = buildSleepingAgentLaunchConfig({
...args,
agentCommand: baseCommand.command
})
const resumeArgs = argv
.slice(1)
.map((arg) => quoteStartupArg(arg, shell))
@@ -228,6 +205,7 @@ export function buildAgentResumeStartupPlan(args: {
launchCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -236,6 +214,7 @@ export type AgentDraftLaunchPlan = {
agent: TuiAgent
launchCommand: string
expectedProcess: string
launchConfig: SleepingAgentLaunchConfig
env?: Record<string, string>
startupCommandDelivery?: StartupCommandDelivery
}
@@ -282,6 +261,10 @@ export function buildAgentDraftLaunchPlan(args: {
if (!baseCommand.ok) {
return null
}
const launchConfig = buildSleepingAgentLaunchConfig({
...args,
agentCommand: baseCommand.command
})
let plan: AgentDraftLaunchPlan | null = null
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, shell)
@@ -289,6 +272,7 @@ export function buildAgentDraftLaunchPlan(args: {
agent,
launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`,
expectedProcess: config.expectedProcess,
launchConfig,
// Why: native draft flags carry user text on argv and must survive rc-file startup.
...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}),
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
@@ -299,6 +283,7 @@ export function buildAgentDraftLaunchPlan(args: {
agent,
launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`,
expectedProcess: config.expectedProcess,
launchConfig,
env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed }
}
}
@@ -309,3 +294,10 @@ export function buildAgentDraftLaunchPlan(args: {
}
export { isShellProcess }
export {
buildShellCommandFromArgv,
planAgentCliArgsSuffix,
quoteStartupArg,
resolveStartupShell
} from './tui-agent-startup-shell'
export type { AgentCliArgsPlan, AgentStartupShell } from './tui-agent-startup-shell'
+4 -1
View File
@@ -30,7 +30,7 @@ import type {
} from './source-control-ai-types'
import type { StartupCommandDelivery } from './codex-startup-delivery'
import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events'
import type { SleepingAgentSessionRecord } from './agent-session-resume'
import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from './agent-session-resume'
import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat'
import type { TerminalCustomTheme } from './terminal-custom-themes'
import type { UiLanguage } from './ui-language'
@@ -1895,6 +1895,9 @@ export type WorktreeSetupLaunch = {
export type WorktreeStartupLaunch = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
}
+162
View File
@@ -127,6 +127,10 @@ describe('parseWorkspaceSession', () => {
updatedAt: 9,
terminalTitle: 'Codex',
lastAssistantMessage: 'done',
launchConfig: {
agentArgs: '',
agentEnv: {}
},
origin: 'live'
}
}
@@ -135,6 +139,164 @@ describe('parseWorkspaceSession', () => {
if (result.ok) {
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex')
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live')
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig).toEqual({
agentArgs: '',
agentEnv: {}
})
}
})
it('drops invalid sleeping agent launch config without dropping the record', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session' },
prompt: 'continue',
state: 'working',
capturedAt: 10,
updatedAt: 9,
launchConfig: {
agentArgs: '--model high',
agentEnv: { 'BAD=KEY': 'value' }
}
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']
expect(record?.agent).toBe('codex')
expect(record?.launchConfig).toBeUndefined()
}
})
it('drops launch config with prototype-polluting env keys without dropping siblings', () => {
const sessions = JSON.parse(`{
"__proto__": {
"paneKey": "__proto__",
"worktreeId": "wt",
"agent": "codex",
"providerSession": { "key": "session_id", "id": "bad-session" },
"prompt": "bad",
"state": "working",
"capturedAt": 10,
"updatedAt": 9
},
"tab1:pane-1": {
"paneKey": "tab1:pane-1",
"tabId": "tab1",
"worktreeId": "wt",
"agent": "codex",
"providerSession": { "key": "session_id", "id": "codex-session" },
"prompt": "continue",
"state": "working",
"capturedAt": 10,
"updatedAt": 9,
"launchConfig": {
"agentArgs": "",
"agentEnv": { "__proto__": "polluted" }
}
}
}`)
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: sessions
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(
Object.prototype.hasOwnProperty.call(
result.value.sleepingAgentSessionsByPaneKey ?? {},
'__proto__'
)
).toBe(false)
const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']
expect(record?.agent).toBe('codex')
expect(record?.launchConfig).toBeUndefined()
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
}
})
it('preserves sleeping agent launch env values with whitespace characters', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session' },
prompt: 'continue',
state: 'working',
capturedAt: 10,
updatedAt: 9,
launchConfig: {
agentArgs: '',
agentEnv: { MULTILINE: 'line1\nline2\tok' }
}
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(
result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig?.agentEnv
).toEqual({ MULTILINE: 'line1\nline2\tok' })
}
})
it('drops sleeping agent launch config with NUL env values without dropping the record', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session' },
prompt: 'continue',
state: 'working',
capturedAt: 10,
updatedAt: 9,
launchConfig: {
agentArgs: '',
agentEnv: { BAD_VALUE: 'ok\0bad' }
}
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']
expect(record?.agent).toBe('codex')
expect(record?.launchConfig).toBeUndefined()
}
})
+1 -43
View File
@@ -20,8 +20,8 @@ import type {
import { isValidTerminalTabId } from './terminal-tab-id'
import { isTuiAgent } from './tui-agent-config'
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume'
import { isWorkspaceKey } from './workspace-scope'
import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents'
// ─── Terminal pane layout (recursive) ───────────────────────────────
@@ -88,48 +88,6 @@ const terminalTabSchema = z.object({
.catch(undefined)
})
// ─── Sleeping agent resume records ─────────────────────────────────
const agentProviderSessionSchema = z.preprocess(
(raw) => normalizeAgentProviderSession(raw) ?? undefined,
z.object({
key: z.enum(['session_id', 'conversation_id']),
id: z.string().min(1).max(512)
})
)
const sleepingAgentSessionRecordSchema = z.object({
paneKey: z.string().refine((value) => value.length > 0),
tabId: terminalTabIdSchema.optional(),
worktreeId: z.string().min(1),
agent: z.enum(RESUMABLE_TUI_AGENTS),
providerSession: agentProviderSessionSchema,
prompt: z.string(),
state: z.enum(['working', 'blocked', 'waiting', 'done']),
capturedAt: z.number().finite().positive(),
updatedAt: z.number().finite().positive(),
terminalTitle: z.string().optional(),
lastAssistantMessage: z.string().optional(),
connectionId: z.string().nullable().optional(),
origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()
})
const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return undefined
}
const cleaned: Record<string, z.infer<typeof sleepingAgentSessionRecordSchema>> = {}
for (const [paneKey, value] of Object.entries(raw as Record<string, unknown>)) {
const parsed = sleepingAgentSessionRecordSchema.safeParse(value)
if (parsed.success && parsed.data.paneKey === paneKey) {
cleaned[paneKey] = parsed.data
}
}
return Object.keys(cleaned).length > 0 ? cleaned : undefined
}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional())
// ─── Unified tab model ──────────────────────────────────────────────
const tabContentTypeSchema = z.enum([
@@ -0,0 +1,104 @@
import { z } from 'zod'
import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume'
import { isValidTerminalTabId } from './terminal-tab-id'
const terminalTabIdSchema = z
.string()
.min(1)
.refine(isValidTerminalTabId, 'terminal tab id must not contain ":"')
const agentProviderSessionSchema = z.preprocess(
(raw) => normalizeAgentProviderSession(raw) ?? undefined,
z.object({
key: z.enum(['session_id', 'conversation_id']),
id: z.string().min(1).max(512)
})
)
function hasUnsafeLaunchEnvChars(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code <= 0x1f || code === 0x7f) {
return true
}
}
return false
}
function isUnsafeObjectKey(value: string): boolean {
return value === '__proto__' || value === 'constructor' || value === 'prototype'
}
const sleepingAgentLaunchEnvSchema = z.preprocess(
(raw) => {
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return undefined
}
const cleaned: Record<string, string> = Object.create(null)
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const trimmedKey = key.trim()
if (
trimmedKey.length === 0 ||
isUnsafeObjectKey(trimmedKey) ||
trimmedKey.includes('=') ||
hasUnsafeLaunchEnvChars(trimmedKey) ||
typeof value !== 'string' ||
value.includes('\0')
) {
return undefined
}
cleaned[trimmedKey] = value
}
return { ...cleaned }
},
z.record(z.string(), z.string())
)
const sleepingAgentLaunchConfigBaseSchema = z.object({
agentCommand: z.string().optional(),
agentArgs: z.string(),
agentEnv: sleepingAgentLaunchEnvSchema
})
export const sleepingAgentLaunchConfigSchema = z.preprocess((raw) => {
const parsed = sleepingAgentLaunchConfigBaseSchema.safeParse(raw)
return parsed.success ? parsed.data : undefined
}, sleepingAgentLaunchConfigBaseSchema.optional())
const sleepingAgentSessionRecordSchema = z.object({
paneKey: z.string().refine((value) => value.length > 0),
tabId: terminalTabIdSchema.optional(),
worktreeId: z.string().min(1),
agent: z.enum(RESUMABLE_TUI_AGENTS),
providerSession: agentProviderSessionSchema,
prompt: z.string(),
state: z.enum(['working', 'blocked', 'waiting', 'done']),
capturedAt: z.number().finite().positive(),
updatedAt: z.number().finite().positive(),
terminalTitle: z.string().optional(),
lastAssistantMessage: z.string().optional(),
connectionId: z.string().nullable().optional(),
launchConfig: sleepingAgentLaunchConfigSchema.optional(),
origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()
})
export const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return undefined
}
const cleaned: Record<string, z.infer<typeof sleepingAgentSessionRecordSchema>> = Object.create(
null
)
for (const [paneKey, value] of Object.entries(raw as Record<string, unknown>)) {
if (isUnsafeObjectKey(paneKey)) {
continue
}
const parsed = sleepingAgentSessionRecordSchema.safeParse(value)
if (parsed.success && parsed.data.paneKey === paneKey) {
cleaned[paneKey] = parsed.data
}
}
return Object.keys(cleaned).length > 0 ? { ...cleaned } : undefined
}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional())