diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts index ff867e7ce03..25bfcb4743f 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-09.spec.ts @@ -50,18 +50,26 @@ describe('OrcaRuntimeService', () => { ) } - function makeParkedRuntime(runtimeStore: unknown): { + function makeParkedRuntime( + runtimeStore: unknown, + host: { + deps?: ConstructorParameters[2] + livePtyIds?: string[] + } = {} + ): { runtime: OrcaRuntimeService spawn: ReturnType } { const spawn = vi.fn().mockResolvedValue({ id: 'persisted-pty' }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store implements the runtime store methods these tests reach. + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, host.deps) runtime.setPtyController({ spawn, write: () => true, kill: () => true, getForegroundProcess: async () => null, - listProcesses: async () => [] + listProcesses: async () => + (host.livePtyIds ?? []).map((id) => ({ id, cwd: '/tmp', title: 'Claude' })) }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) return { runtime, spawn } @@ -90,6 +98,21 @@ describe('OrcaRuntimeService', () => { runtime.markGraphReady(TEST_WINDOW_ID) } + function firstSpawnArgs(spawn: ReturnType): { + command?: string + sessionId?: string + } { + const args: unknown = spawn.mock.calls[0]?.[0] + if (typeof args !== 'object' || args === null) { + return {} + } + const command = + 'command' in args && typeof args.command === 'string' ? args.command : undefined + const sessionId = + 'sessionId' in args && typeof args.sessionId === 'string' ? args.sessionId : undefined + return { command, sessionId } + } + const userActivate = ( runtime: OrcaRuntimeService, leafId?: string @@ -190,6 +213,214 @@ describe('OrcaRuntimeService', () => { expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) }) + // #27147 / #27777: the tap used to start a bare shell (or a fresh agent), which lost the + // conversation the host recorded for this pane. The host window resumes it; so must this. + it('resumes the slept agent session when a paired client opens the pane', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + launchConfig: { agentArgs: '--dangerously-skip-permissions', agentEnv: {} } + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + setParkedRuntimeNotifier(runtime, vi.fn()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only isDestroyed from the window mock. + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + const activated = await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + const spawnArgs = firstSpawnArgs(spawn) + expect(spawnArgs.sessionId).toBe('persisted-pty') + expect(spawnArgs.command).toContain('--resume') + expect(spawnArgs.command).toContain('provider-session-1') + expect(spawnArgs.command).toContain('--dangerously-skip-permissions') + expect(activated.tabs[0]).toMatchObject({ status: 'ready' }) + }) + + it('resumes from a live anchor when the pane was lost while its agent ran', async () => { + const { runtimeStore } = makeParkedSessionStore('live') + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command).toContain('provider-session-1') + }) + + // Why: Codex resume routing keys off the launch agent, so it must name the resumed agent. + it('labels the spawn with the recorded agent, not the agent the tab first launched', async () => { + const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep', { + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' } + }) + const session = structuredClone(getSession()) + const tab = session.tabsByWorktree[TEST_WORKTREE_ID]?.[0] + if (tab) { + tab.launchAgent = 'claude' + } + setSession(session) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command).toContain('codex-session-1') + expect(spawn.mock.calls[0]?.[0]).toMatchObject({ launchAgent: 'codex' }) + }) + + it('does not resume a session another pane also records', async () => { + const { runtimeStore, getSession, setSession } = makeParkedSessionStore('worktree-sleep') + const session = structuredClone(getSession()) + const records = session.sleepingAgentSessionsByPaneKey ?? {} + const own = records[`host-tab:${HEADLESS_LEAF_ID}`] + if (own) { + records[`host-tab:${HEADLESS_SECOND_LEAF_ID}`] = { + ...own, + paneKey: `host-tab:${HEADLESS_SECOND_LEAF_ID}` + } + } + setSession(session) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + + // Why: a resume is restore work; paused or rate-limited restore admission must see it. + it('declares a reconnect resume as automatic restore and a tap as a user resume', async () => { + const automatic = makeParkedRuntime(makeParkedSessionStore('live').runtimeStore) + await automaticActivate(automatic.runtime) + expect(automatic.spawn.mock.calls[0]?.[0]).toMatchObject({ + spawnIntent: 'automatic-restore' + }) + + const tap = makeParkedRuntime(makeParkedSessionStore('live').runtimeStore) + await userActivate(tap.runtime) + expect(tap.spawn.mock.calls[0]?.[0]).toMatchObject({ spawnIntent: 'user-resume' }) + }) + + function makeRuntimeWithOtherPaneHolder(livePtyIds: string[]) { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const parked = makeParkedRuntime(runtimeStore, { + livePtyIds, + deps: { + getAgentProviderSessionSnapshot: () => [ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession. + { + paneKey: `other-tab:${HEADLESS_SECOND_LEAF_ID}`, + providerSession: { key: 'session_id', id: 'provider-session-1' } + } as never + ] + } + }) + parked.runtime.registerPty('other-pty', TEST_WORKTREE_ID, null, { + tabId: 'other-tab', + leafId: HEADLESS_SECOND_LEAF_ID + }) + return parked + } + + it('does not resume a session the hook store places in another live pane', async () => { + const { runtime, spawn } = makeRuntimeWithOtherPaneHolder(['other-pty']) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + + // Why: losing sight of the holder (a dropped SSH relay) is not evidence it stopped writing. + it('does not resume while the other pane holding the session is unverifiable', async () => { + const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([]) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + + it('does not resume while the other holder has no host terminal record yet', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore, { + deps: { + getAgentProviderSessionSnapshot: () => [ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession. + { + paneKey: `other-tab:${HEADLESS_SECOND_LEAF_ID}`, + providerSession: { key: 'session_id', id: 'provider-session-1' } + } as never + ] + } + }) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + + it('resumes once the other pane holding the session has proven exited', async () => { + const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([]) + runtime.onPtyExit('other-pty', 0) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command).toContain('provider-session-1') + }) + + it('does not resume a record older than the pane conversation the hook store names', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep') + const { runtime, spawn } = makeParkedRuntime(runtimeStore, { + deps: { + getAgentProviderSessionRowsForPane: (paneKey) => + paneKey === `host-tab:${HEADLESS_LEAF_ID}` + ? [ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only providerSession. + { paneKey, providerSession: { key: 'session_id', id: 'newer-session' } } as never + ] + : [] + } + }) + + await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + + // Why: Pi identities are id plus transcript; a shared id alone does not name one conversation. + it('does not treat another Pi transcript with the same id as this pane session', async () => { + const piSession = { key: 'session_id', id: 'pi-1', transcriptPath: '/t/a.jsonl' } as const + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + agent: 'pi', + providerSession: piSession + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore, { + deps: { + getAgentProviderSessionRowsForPane: (paneKey) => [ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only providerSession. + { paneKey, providerSession: { ...piSession, transcriptPath: '/t/b.jsonl' } } as never + ] + } + }) + + await userActivate(runtime) + + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('/t/a.jsonl') + }) + + // Why: a session id names a transcript on the machine that captured it. + it('does not resume a record captured on another execution host', async () => { + const { runtimeStore } = makeParkedSessionStore('worktree-sleep', { + connectionId: 'ssh-elsewhere' + }) + const { runtime, spawn } = makeParkedRuntime(runtimeStore) + + await userActivate(runtime) + + expect(spawn).toHaveBeenCalledOnce() + expect(firstSpawnArgs(spawn).command ?? '').not.toContain('provider-session-1') + }) + // Why: manual sleep of a finished agent stamps restoreOnTabOpenOnly, which the // background wake skips and resume classifies pane-owned, so the record survives. it('materializes a slept pane whose completed-agent record is restore-on-tab-open-only', async () => { diff --git a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts index 594826228ec..0735479d1f5 100644 --- a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts @@ -14,6 +14,14 @@ import { projectClientSessionTabSelection } from '../../client-session-tab-selection' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + agentProviderSessionsEqual, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { + MobileTerminalResumeCommand, + OrcaRuntimeWithResolveMobileSessionTerminalCommand as MobileTerminalStartup +} from '../../terminal/launch/mobile-terminal-startup' import { runtimeWorktreeIdsEqual } from '../../runtime-worktree-path-identity' import { buildHeadlessMobileSessionTabGroups, @@ -24,6 +32,9 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc protected abstract emitMobileSessionTabsSnapshotToClient( ...args: Parameters ): ReturnType + protected abstract resolveMobileSessionResumeCommand( + ...args: Parameters + ): ReturnType protected abstract isHeadlessMobileSessionPublication( ...args: Parameters ): ReturnType @@ -115,16 +126,112 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { + // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect + // must still mint its replacement PTY (#11542). Only a worktree-owned capture records + // a deliberate takedown the user did not ask to undo. + return this.getPaneSleepingAgentRecord(worktreeId, tab)?.origin === 'worktree-sleep' + } + + /** + * The record the host window's cold restore would resume this pane from, if the pane owns it. + * Why: a running agent's worktree-sleep record is relaunched in a fresh tab by the window's + * worktree activation, so resuming it in the pane as well would run one transcript twice. + */ + protected getPaneOwnedResumeRecord( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): SleepingAgentSessionRecord | null { + const record = this.getPaneSleepingAgentRecord(worktreeId, tab) + if (!record) { + return null + } + const paneOwned = + record.origin === 'live' || record.origin === 'quit' || record.state === 'done' + return paneOwned ? record : null + } + + /** + * The pane's resume launch: undefined when it owns no record, `{}` (plain shell, record kept, + * as the window does) when the record cannot be resumed here. + */ + protected async resolvePaneOwnedResumeStartup( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): Promise | undefined> { + const record = this.getPaneOwnedResumeRecord(worktreeId, tab) + if (!record) { + return undefined + } + // Why: nothing here spends the record, so a session another pane also records could be + // resumed twice (two writers on one transcript), as could one already live in another pane. + const records = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey + const sharedWithAnotherPane = Object.values(records ?? {}).some( + (other) => + other !== record && + other.agent === record.agent && + agentProviderSessionsEqual(record.agent, other.providerSession, record.providerSession) + ) + if ( + sharedWithAnotherPane || + this.isProviderSessionLiveInAnotherPane(record) || + this.hookStoreNamesAnotherSessionForPane(record) + ) { + return {} + } + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + return this.resolveMobileSessionResumeCommand(workspace, record) ?? {} + } catch { + return {} + } + } + + /** The host's hook store places this conversation in another pane not proven exited. */ + private isProviderSessionLiveInAnotherPane(record: SleepingAgentSessionRecord): boolean { + const otherPaneKeys = new Set( + (this.getAgentProviderSessionSnapshotFn?.() ?? []) + .filter( + (row) => + row.paneKey !== record.paneKey && + agentProviderSessionsEqual(record.agent, row.providerSession, record.providerSession) + ) + .map((row) => row.paneKey) + ) + if (otherPaneKeys.size === 0) { + return false + } + // Why: only a proven exit clears a holder; an unverifiable PTY (dropped SSH relay) or one not + // yet hydrated after a restart may still be writing the transcript. + const provenExitedPaneKeys = new Set() + for (const pty of this.ptysById.values()) { + if (pty.paneKey && otherPaneKeys.has(pty.paneKey)) { + if (!this.isPtyKnownExited(pty.ptyId)) { + return true + } + provenExitedPaneKeys.add(pty.paneKey) + } + } + return [...otherPaneKeys].some((paneKey) => !provenExitedPaneKeys.has(paneKey)) + } + + /** The pane moved on to another conversation after the record was saved; the hook store is authoritative. */ + private hookStoreNamesAnotherSessionForPane(record: SleepingAgentSessionRecord): boolean { + return (this.getAgentProviderSessionRowsForPaneFn?.(record.paneKey) ?? []).some( + (row) => + row.providerSession !== undefined && + !agentProviderSessionsEqual(record.agent, row.providerSession, record.providerSession) + ) + } + + private getPaneSleepingAgentRecord( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): SleepingAgentSessionRecord | null { const record = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ makePaneKey(tab.parentTabId, tab.leafId) ] - // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect - // must still mint its replacement PTY (#11542). Only a worktree-owned capture records - // a deliberate takedown the user did not ask to undo. - return ( - record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) - ) + return record && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) ? record : null } protected shouldMaterializeHeadlessMobileSessionTab( diff --git a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts index fd655dc97cf..2652bca72e4 100644 --- a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts @@ -1,7 +1,10 @@ import type { RuntimeWorkspaceSelectors as WorkspaceSelectors } from '../../workspaces/workspace-selectors' import type { OrcaRuntimeWithApplyMobileSessionTabNavigation as MobileSessionNavigation } from './apply-mobile-session-tab-navigation' import type { OrcaRuntimeWithStopTerminalsForWorktree as WorktreeTerminalStop } from '../../terminal/stop/worktree-terminal-stop' -import type { MobileTerminalCommand } from '../../terminal/launch/mobile-terminal-startup' +import type { + MobileTerminalCommand, + MobileTerminalResumeCommand +} from '../../terminal/launch/mobile-terminal-startup' import { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs } from './build-headless-mobile-session-browser-tabs' import type { PtyControllerInventory } from '../../runtime-pty-controller-contract' import { isAgentLaunchRunningIn } from '../../../agent-launch/agent-launch-pane-attachment' @@ -32,6 +35,9 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten protected abstract isDeliberatelyParkedPane( ...args: Parameters ): ReturnType + protected abstract resolvePaneOwnedResumeStartup( + ...args: Parameters + ): ReturnType protected abstract isWorktreeTerminalSleepHeld( ...args: Parameters ): ReturnType @@ -199,8 +205,11 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten // bare materialize would put a plain shell under the agent icon. // Re-resolve the launch like the create path; providers skip startup // commands when attaching to live sessions, so this cannot double-launch. - let agentStartup: MobileTerminalCommand = {} - if (tab.launchAgent) { + // Why: an agent already ran here and the host recorded its session; resume it rather than + // start a bare shell or a fresh agent (#27147, #27777). + const resumeStartup = await this.resolvePaneOwnedResumeStartup(worktreeId, tab) + let agentStartup: MobileTerminalCommand | MobileTerminalResumeCommand = resumeStartup ?? {} + if (resumeStartup === undefined && tab.launchAgent) { let workspace try { workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) @@ -228,7 +237,14 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten env: agentStartup.env, startupCommandDelivery: agentStartup.startupCommandDelivery, launchConfig: agentStartup.launchConfig, - launchAgent: tab.launchAgent, + launchAgent: agentStartup.launchAgent ?? tab.launchAgent, + // Why: a resume is restore work, so the host's restore admission must see it. + ...('resumeProviderSession' in agentStartup + ? { + resumeProviderSession: agentStartup.resumeProviderSession, + spawnIntent: automatic ? 'automatic-restore' : 'user-resume' + } + : {}), launchRuntime: agentStartup.launchRuntime, targetGroupId, ...(automatic ? { refuseSleptWorktree: true } : {}) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index fe3173cd189..43e5d7b6e0b 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -1,7 +1,11 @@ import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './mobile-terminal-startup' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { SpawnIntent } from '../../../../shared/host-spawn-admission' import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' import type { RuntimeMobileSessionCreateTerminalResult, @@ -34,6 +38,8 @@ export type OwnedMobileTerminalCreateOptions = { targetGroupId?: string supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + spawnIntent?: SpawnIntent launchRuntime?: AgentLaunchRuntime signal?: AbortSignal refuseSleptWorktree?: boolean @@ -112,6 +118,10 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext env: opts.env, envToDelete: opts.envToDelete, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), + ...(opts.resumeProviderSession + ? { resumeProviderSession: opts.resumeProviderSession } + : {}), + ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts index 487a6d3a1ea..acf58e89536 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts @@ -2,9 +2,22 @@ import { OrcaRuntimeWithRunCreateMobileSessionTerminal } from './mobile-terminal import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import { + isResumableTuiAgent, + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { buildAgentStartupPlan } from '../../../../shared/tui-agent-startup' +import { + buildAgentResumeStartupPlan, + buildAgentStartupPlan +} from '../../../../shared/tui-agent-startup' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../../shared/tui-agent-launch-defaults' import { resolveAgentStartupPlanInputs } from '../../../../shared/agent-startup-plan-inputs' import { agentLaunchQuotingShell, @@ -33,7 +46,71 @@ export type MobileTerminalCommand = { launchRuntime?: AgentLaunchRuntime } +export type MobileTerminalResumeCommand = MobileTerminalCommand & { + command: string + resumeProviderSession: AgentProviderSessionMetadata +} + +function normalizeOriginConnectionId(connectionId: string | null | undefined): string | null { + return connectionId?.trim() || null +} + export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { + /** The `--resume` launch for a pane's recorded agent session, as the host window's cold restore builds it. */ + protected resolveMobileSessionResumeCommand( + workspace: TerminalWorkspaceLaunchScope, + record: SleepingAgentSessionRecord + ): MobileTerminalResumeCommand | null { + const providerSession = normalizeAgentProviderSession(record.providerSession) + if (!this.store || !providerSession || !isResumableTuiAgent(record.agent)) { + return null + } + // Why: a session id names a transcript on the machine that captured it; elsewhere it cannot resume. + if ( + record.connectionId !== undefined && + normalizeOriginConnectionId(record.connectionId) !== + normalizeOriginConnectionId(workspace.connectionId) + ) { + return null + } + const settings = this.store.getSettings() + if (!isTuiAgentEnabled(record.agent, settings.disabledTuiAgents)) { + return null + } + const launchRuntime = this.resolveAgentLaunchRuntime(workspace, record.agent) + const { launchConfig } = record + const startupPlan = buildAgentResumeStartupPlan({ + agent: record.agent, + providerSession, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: launchConfig + ? launchConfig.agentArgs + : resolveTuiAgentLaunchArgs(record.agent, settings.agentDefaultArgs), + agentEnv: launchConfig + ? launchConfig.agentEnv + : resolveTuiAgentLaunchEnv(record.agent, settings.agentDefaultEnv), + ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + ...(launchConfig?.ompResumeFilePath + ? { ompResumeFilePath: launchConfig.ompResumeFilePath } + : {}), + platform: launchRuntime.platform, + shell: agentLaunchQuotingShell(launchRuntime), + isRemote: Boolean(workspace.connectionId) + }) + if (!startupPlan) { + return null + } + return { + command: startupPlan.launchCommand, + env: startupPlan.env, + launchConfig: startupPlan.launchConfig, + launchAgent: record.agent, + startupCommandDelivery: startupPlan.startupCommandDelivery, + launchRuntime, + resumeProviderSession: providerSession + } + } + protected async resolveMobileSessionTerminalCommand( workspace: TerminalWorkspaceLaunchScope, opts: MobileTerminalCommandOptions