diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index d62ed98395c..07f8e834d9d 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f9cb59fec81..567bf392ac0 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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, diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts index ad56b46413d..48ea7f01e11 100644 --- a/src/main/amp/hook-service.ts +++ b/src/main/amp/hook-service.ts @@ -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,', diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index 8a019acd4f4..e78d4cf5f59 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -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 { diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index c6a3229478d..38d09fecbf5 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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}" \\', diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index aa9b6f7b060..f53a180a15f 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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}" \\', diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index f3051d29291..c6b8bf1b8b9 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -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=') } diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 5d6161e6c69..435344bd97c 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -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}" \\', diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index c5b8ae1ec2b..9dd8ec38154 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -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}" \\', diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index e66a59bd3f6..3b74d2538d3 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -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}" \\', diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 769797ee94d..55fa199f5a2 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 1a2f6a3d05d..ba9e1428beb 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -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}" \\', diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 0f29c6dd82b..9b444d0d11a 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -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}" \\', diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 486c4752ebf..ac687f6c6be 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -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}" \\', diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index d26bf8bb80e..eefc6dae5fa 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -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}" \\', diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index 2c9ea3f498c..a1c1995a24d 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -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", ""), diff --git a/src/main/index.ts b/src/main/index.ts index 871c8feaff1..d684845923a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 1ce07eabc5b..7a9c73db4ba 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 } } + + const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } + 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: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ada5767afde..1d02b3deb4c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 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 | 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 } + : {}) + } } ) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 216465caff5..50fb87e58fb 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 03b640761b0..3b7130d040d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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', diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 2b5177b789b..ec715ac4d40 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -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}" \\', diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index ddc727d534c..6a734d4a8d1 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -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,', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index d6ff3d811c4..48172dfbf0c 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -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,', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 1425483906f..3d5732fd1d9 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5d6abfb6533..5eae08671df 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 } | 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 } + | 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 } + | 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 }) ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0fd1d470436..67fa7ed835e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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; 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 + 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 startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent activate?: boolean } = {} ): Promise { @@ -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 { + opts: { + command?: string + env?: Record + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + agent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + } + ): Promise<{ + command?: string + env?: Record + 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, startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'], identity?: { tabId: string; leafId: string; sessionId?: string }, launchAgent?: TuiAgent, - targetGroupId?: string + targetGroupId?: string, + launchConfig?: SleepingAgentLaunchConfig ): Promise { 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 + }): Promise<{ env: Record }> { 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, diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 37be39d560f..4071db4a1ee 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -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(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(isTuiAgent, { + message: 'Unknown launch agent' + }) + .optional(), activate: z.boolean().optional() }) diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 20112ef377a..4060824491b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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 }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 28354741e34..3f8c03788c9 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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 }) }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 0234530ff12..e043459e106 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 8382b2b268b..97608177845 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -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. diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 00f835cef34..9381f80ecc3 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9475886f34b..7bb3252ec8c 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -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 } : {}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 1f7c3d52d2b..ecbf8b280c1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 583437ff3b1..ce720ca612c 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 295212b4e27..91f58d645d3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 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 + 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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean diff --git a/src/preload/index.ts b/src/preload/index.ts index a6db5a7ce49..5611df3becf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 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 + 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 + 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 + 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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index eb9df3348b3..8fa65ed352c 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -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, diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 4e42488436c..950378ae1d1 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -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 } : {}), diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index ceef964dd40..73571a2f330 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -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', diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index b75e893ece3..10c5cd76cd1 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -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') diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 8f8ef6629fe..5786d409aba 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -222,7 +222,7 @@ export function ExperimentalPane({

{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.' )}

diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 1fdf355b999..8ce3096ebdd 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 37de0bc4973..1d3d26a77f9 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index c3fe8a3e2b5..6f6ad0f4b4d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -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 + 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'> diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 85e95df4529..dbb77709d79 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 + agentDefaultArgs?: Record + agentDefaultEnv?: Record> } | null codexRestartNoticeByPtyId: Record< string, @@ -109,7 +112,11 @@ type StoreState = { runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record + agentLaunchConfigByPaneKey: Record + getAgentLaunchConfigForStatusEntry: ReturnType clearSleepingAgentSession: ReturnType + registerAgentLaunchConfig: ReturnType + clearAgentLaunchConfig: ReturnType markWorktreeUnread: ReturnType observeTerminalGitHubPullRequestLink: ReturnType recordTerminalInput: ReturnType @@ -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 + }) 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() @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 844d4aeb59f..b5349332cac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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>() @@ -180,6 +183,19 @@ type E2eTerminalPtyOutputDebugWindow = Window & { __terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi } +type PendingStartupCommand = { + command: string + env?: Record +} + +type ColdRestoreAgentResumeStartup = PendingStartupCommand & { + agent: ResumableTuiAgent + launchConfig: NonNullable>['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 | undefined + ): Record | 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 = 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() diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 13615896d30..eefd4fb46a5 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery callbacks: { onConnect?: () => void onDisconnect?: () => void @@ -369,6 +378,9 @@ export type IpcPtyTransportOptions = { cwd?: string env?: Record command?: string + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null /** Orca worktree identity for scoped shell history. */ diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 1e9392bfcbf..79049d62997 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -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)) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index f6d6b703985..ee1ffaa419d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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') { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 6b50908c26f..0b9ebff92e9 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index d013ab76584..8b89522a0de 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 } : {}), diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 3f4a308ec2a..e6b4d46cd36 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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, @@ -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() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3a18613039b..a369db8d130 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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, paneKey: string, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 793dd0157c4..bf4074251f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4d8790eab5..a623cb0fbf4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 98999aeaa52..d903898e9b6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 00d49041221..ef4e24d6b4d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cd8f075e8bb..a694bf47c12 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "终端", diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 8e398004e40..11bbcfb170a 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -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', diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 0e95c0753b3..ba4e7f033f5 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -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) } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 9e34e0a3e98..4b3c18d968c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -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, + agentDefaultEnv: {} as Record>, + 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({ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index df11c678f70..1ffc486397a 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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 } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index 3422f078b99..29d373bf546 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -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', diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 2260e747b90..f9a039ae62f 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -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 + 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 } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 5740e537399..8d8763cdb18 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -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' } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index d941deb724e..b853f7e33d4 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -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 } : {}), diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 4ea5593519e..4626a06d259 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -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' } }) diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index e551500e3a7..81bb5431609 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -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 + 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 } : {}), diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 107f9742495..bd9766cf141 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -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" }, diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 59d4f02e5c0..6d7b7b9c7b7 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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 } : {}), diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index f1cf4ff0c4b..d31cf7c3a86 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -111,6 +111,7 @@ export function planSourceControlAgentActionLaunch(args: { launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, + launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 97d6aa1d234..1feb1e44c7a 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -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') }) }) }) diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index ee141ba9a74..a61e9003d60 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -3,7 +3,9 @@ export { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, - isShellProcess + isShellProcess, + quoteStartupArg, + resolveStartupShell } from '../../../shared/tui-agent-startup' export type { AgentCliArgsPlan, diff --git a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts new file mode 100644 index 00000000000..68196ad5f55 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts @@ -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> = {} +): { + markWorktreeVisited: ReturnType + recordWorktreeVisit: ReturnType + revealWorktreeInSidebar: ReturnType +} { + 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['settings'], + markWorktreeVisited, + recordWorktreeVisit, + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar, + ...overrides + }) + + return { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar } +} diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index bc9646ab6bb..26cd3f08986 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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> = {} -): { - markWorktreeVisited: ReturnType - recordWorktreeVisit: ReturnType - revealWorktreeInSidebar: ReturnType -} { - 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['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', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index fe0dd825558..492c92913f0 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery initialAgentStatus?: { agent: TuiAgent; prompt: string } telemetry?: AgentStartedTelemetry @@ -103,6 +107,8 @@ type WorktreeActivationStore = Partial & { startup: { command: string env?: Record + 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 } : {}), diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 87f09189089..052777529c4 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 429412c52ce..cc345bfcffe 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -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 diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index d6098debe5b..f23188b4a00 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -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 startupCommandDelivery?: StartupCommandDelivery + launchConfig?: SleepingAgentLaunchConfig agent?: TuiAgent + launchAgent?: TuiAgent activate?: boolean selectWorktree?: boolean }): Promise { @@ -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 diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index 97addb030ce..271fc9495b9 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -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) + 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) 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) + 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) + 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) + 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) + 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) + 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) + + 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) + 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) + + 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) + + 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) + 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', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 172107f5691..59e802aedce 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -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 + /** Ephemeral launch snapshots keyed by concrete pane. Hook payloads do not + * carry Orca launch settings, so the renderer supplies them from startup. */ + agentLaunchConfigByPaneKey: Record + /** 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 ) => 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, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -494,6 +687,7 @@ export const createAgentStatusSlice: StateCreator { @@ -555,6 +749,89 @@ export const createAgentStatusSlice: StateCreator { + 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 entry.paneKey === paneKey ) - const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] const liveRecoveryWorktreeId = entry.state === 'done' ? null @@ -753,10 +1075,38 @@ export const createAgentStatusSlice: StateCreator { 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 entry.paneKey === paneKey @@ -860,6 +1219,7 @@ export const createAgentStatusSlice: StateCreator 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 entry.paneKey?.startsWith(prefix) ?? false @@ -905,6 +1272,7 @@ export const createAgentStatusSlice: StateCreator { 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 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 entry.paneKey === paneKey @@ -1161,7 +1561,13 @@ export const createAgentStatusSlice: StateCreator 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 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 { 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 { let changed = false const next: Record = {} + 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 { let changed = false const next: Record = {} + 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 }) }, diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index bc72bdda566..a18a2733dcc 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -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', diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 9abe387be50..1c53d3e671d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 + 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 + 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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent initialAgentStatus?: { agent: TuiAgent; prompt: string } showSessionRestoredBanner?: boolean telemetry?: AgentStartedTelemetry @@ -2328,10 +2338,18 @@ export const createTerminalSlice: StateCreator }, 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 } : {}) + } } })) }, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 0c99b4a31b0..66acbf65fdd 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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 }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 4a84f9ffd85..2153ee068f0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1836,6 +1836,9 @@ export const createWorktreeSlice: StateCreator ? { startupCommand: startup.command, ...(startup.env ? { startupEnv: startup.env } : {}), + ...(startup.launchConfig + ? { startupLaunchConfig: startup.launchConfig } + : {}), ...(startup.startupCommandDelivery ? { startupCommandDelivery: startup.startupCommandDelivery } : {}), diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index e53721f8072..924de21e683 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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 }) }, { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 14454de5c9e..a50f78322dd 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1159,6 +1159,19 @@ function createWorktreesApi(): NonNullable['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, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index cc4ee6d31b9..ba0d5acde1f 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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 let promptInteractionKey: string | undefined @@ -3132,6 +3135,7 @@ export function normalizeHookPayload( return payload ? { paneKey, + launchToken, tabId, worktreeId, connectionId: null, diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index c378e27f0c0..df3ddcf9910 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -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. */ diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 4407e58c259..ae3568372c2 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -22,6 +22,12 @@ export type AgentProviderSessionMetadata = { id: string } +export type SleepingAgentLaunchConfig = { + agentCommand?: string + agentArgs: string + agentEnv: Record +} + 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, diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 29810543105..9c199550d53 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -168,6 +168,7 @@ export type ParsedAgentStatusPayload = Omit & { pr */ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { paneKey: string + launchToken?: string terminalHandle?: string tabId?: string worktreeId?: string diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts new file mode 100644 index 00000000000..db1cee17017 --- /dev/null +++ b/src/shared/tui-agent-startup-shell.ts @@ -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(' ') + } +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 78868880368..e40726b6a92 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -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' } }) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 482a1939398..274869eee69 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -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 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> @@ -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 | 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 | 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 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' diff --git a/src/shared/types.ts b/src/shared/types.ts index f9a8472934c..1ddd0af588e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } } diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 27320f51b35..76870f1d0b5 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -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).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() } }) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 1f7898bef36..6cc9e773e21 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -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> = {} - for (const [paneKey, value] of Object.entries(raw as Record)) { - 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([ diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts new file mode 100644 index 00000000000..a05731a89cb --- /dev/null +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -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 = Object.create(null) + for (const [key, value] of Object.entries(raw as Record)) { + 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> = Object.create( + null + ) + for (const [paneKey, value] of Object.entries(raw as Record)) { + 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())