diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 4b49990a566..c66e7d27b5d 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/runtime-file-contracts' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result' import type { AgentLaunchTabPublished, AgentLaunchTabViewerRule, @@ -102,6 +103,8 @@ export type RuntimeNotifier = { ): Promise /** A launch pane's fate, for the window to keep on its tab or act on. */ agentLaunchPaneVerdict?(event: AgentLaunchPaneVerdictEvent): void + /** A launch's recipe when no reveal carried it, so resume can replay it. */ + rememberLaunchRecipe?(recipe: PaneLaunchRecipe): void resolveLegacyWorkerTerminalRecovery?( paneKey: string, resolution: 'adopted' | 'exited' | 'rolled_back', diff --git a/src/main/runtime/terminal/launch/terminal-create-reveal.test.ts b/src/main/runtime/terminal/launch/terminal-create-reveal.test.ts new file mode 100644 index 00000000000..9c58521b65e --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-create-reveal.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { revealCreatedTerminal } from './terminal-create-reveal' + +const launchConfig = { agentArgs: '--dangerously-skip-permissions', agentEnv: {} } +const recipe = { + tabId: 'tab', + leafId: 'leaf', + launchConfig, + launchToken: 'token', + launchAgent: 'claude' +} + +async function reveal(args: { + presentation: 'background' | 'focused' + revealFails?: boolean + adoptedStablePane?: boolean +}) { + const notifier = { + revealTerminalSession: vi.fn(async () => { + if (args.revealFails) { + throw new Error('Terminal reveal timed out') + } + return { tabId: 'tab' } + }), + rememberLaunchRecipe: vi.fn() + } + const result = await revealCreatedTerminal({ + notifier, + workspace: { id: 'wt', path: '/repo' }, + ptyId: 'pty', + handle: 'term_1', + cwd: '/repo', + launchOpts: { launchAgent: 'claude' }, + effectiveLaunchConfig: launchConfig, + launchToken: 'token', + adoptedStablePane: args.adoptedStablePane ?? false, + presentation: args.presentation, + opts: {}, + tabId: 'tab', + leafId: 'leaf' + }) + return { notifier, surface: result.surface } +} + +describe('revealCreatedTerminal launch recipe', () => { + it.each([ + { name: 'a background launch', presentation: 'background' as const }, + { name: 'a launch whose reveal failed', presentation: 'focused' as const, revealFails: true } + ])('tells the window the recipe of $name', async (args) => { + const { notifier, surface } = await reveal(args) + + expect(surface).toBe('background') + expect(notifier.rememberLaunchRecipe).toHaveBeenCalledWith(recipe) + }) + + it('never re-registers an adopted pane, whose launch token is already spent', async () => { + const { notifier } = await reveal({ presentation: 'background', adoptedStablePane: true }) + + expect(notifier.rememberLaunchRecipe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/terminal/launch/terminal-create-reveal.ts b/src/main/runtime/terminal/launch/terminal-create-reveal.ts index 825e62dbb99..17fcd19e66a 100644 --- a/src/main/runtime/terminal/launch/terminal-create-reveal.ts +++ b/src/main/runtime/terminal/launch/terminal-create-reveal.ts @@ -4,7 +4,7 @@ import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-t import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-types' export function revealCreatedTerminal(args: { - notifier: RuntimeNotifier | null + notifier: Pick | null workspace: Pick ptyId: string handle: string @@ -12,6 +12,8 @@ export function revealCreatedTerminal(args: { launchOpts: dependencies.TerminalCreateOptions effectiveLaunchConfig: dependencies.TerminalCreateOptions['launchConfig'] launchToken: string | undefined + /** An adopted pane already ran; its recipe and spent launch token must not be re-registered. */ + adoptedStablePane: boolean presentation: RuntimeTerminalPresentation | undefined opts: dependencies.TerminalCreateOptions tabId: string @@ -28,12 +30,24 @@ export function revealCreatedTerminal(args: { launchOpts, effectiveLaunchConfig, launchToken, + adoptedStablePane, presentation, opts, tabId, leafId } = args const reveal = notifier?.revealTerminalSession?.bind(notifier) + const rememberLaunchRecipe = (): void => { + if (effectiveLaunchConfig && !adoptedStablePane) { + notifier?.rememberLaunchRecipe?.({ + tabId, + leafId, + launchConfig: effectiveLaunchConfig, + ...(launchToken ? { launchToken } : {}), + ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}) + }) + } + } if (presentation !== 'background' && reveal) { return (async () => { try { @@ -54,6 +68,7 @@ export function revealCreatedTerminal(args: { return { surface: 'visible' as const } } catch (err) { console.warn(`[terminal-create] failed to create inactive tab for ${ptyId}:`, err) + rememberLaunchRecipe() return { surface: 'background' as const, warning: dependencies.createTerminalRevealWarning(handle, err) @@ -61,6 +76,7 @@ export function revealCreatedTerminal(args: { } })() } + rememberLaunchRecipe() return { surface: 'background', warning: diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 7a57eead259..4c8e8427610 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -279,6 +279,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi }, effectiveLaunchConfig: pty?.launchConfig ?? undefined, launchToken: pty?.launchToken ?? undefined, + adoptedStablePane: launch.adoptedStablePane, presentation, opts, tabId, diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index b027404350d..6b0a516baf9 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -153,6 +153,7 @@ export function registerRuntimeWindowLifecycle( publishAgentLaunchTab: (request) => requestAgentLaunchTabPublishFromRenderer(mainWindow, request), agentLaunchPaneVerdict: (event) => send('ui:agentLaunchPaneVerdict', event), + rememberLaunchRecipe: (recipe) => send('ui:launchRecipe', recipe), resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) => send('agentStatus:legacyWorkerTerminalRecovery', { paneKey, diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index b24f01518d1..62423bf50b6 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -11,6 +11,7 @@ import type { AgentLaunchTabPublishRequest } from '../../shared/agent-launch-tab-publication' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' +import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result' import type { TerminalPaneRevealReply, TerminalPaneRevealRequest @@ -142,6 +143,11 @@ export const uiTerminalAndSessionTabsApi = { ipcRenderer.on('ui:agentLaunchPaneVerdict', listener) return () => ipcRenderer.removeListener('ui:agentLaunchPaneVerdict', listener) }, + onLaunchRecipe: (callback: (data: PaneLaunchRecipe) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: PaneLaunchRecipe) => callback(data) + ipcRenderer.on('ui:launchRecipe', listener) + return () => ipcRenderer.removeListener('ui:launchRecipe', listener) + }, onSplitTerminal: ( callback: (data: { tabId: string diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index a4260a2377e..f1303c2450e 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -16,6 +16,7 @@ import type { AgentLaunchTabPublishRequest } from '../../shared/agent-launch-tab-publication' import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' +import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result' import type { TerminalPaneRevealReply, TerminalPaneRevealRequest @@ -214,6 +215,7 @@ export type UiCommandEventApi = { onPublishAgentLaunchTab: (callback: (data: AgentLaunchTabPublishRequest) => void) => () => void replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply) => void onAgentLaunchPaneVerdict: (callback: (data: AgentLaunchPaneVerdictEvent) => void) => () => void + onLaunchRecipe: (callback: (data: PaneLaunchRecipe) => void) => () => void onRevealTerminalPane: (callback: (data: TerminalPaneRevealRequest) => void) => () => void replyTerminalPaneReveal: (reply: TerminalPaneRevealReply) => void onSplitTerminal: ( diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index ed9ac393d70..465803d0449 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -50,6 +50,7 @@ export function buildWindowApi(args: { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index 80e286dd1ae..5b05c88e89f 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -180,6 +180,7 @@ export async function useIpcEventsForCloseRouting({ onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts index fb3730e5ab5..f95915b9fe6 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts @@ -98,6 +98,7 @@ export function buildTerminalCreateWindow(args: { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, onFocusTerminal: ( diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 888c9cf1a7c..7a6edef5a59 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -162,6 +162,7 @@ export async function loadIpcEventsHarness( createTerminalListener = listener return () => {} }, + onLaunchRecipe: () => () => {}, onRequestTerminalCreate: (listener: (request: RequestTerminalCreateRequest) => void) => { requestTerminalCreateListener = listener return () => {} diff --git a/src/renderer/src/hooks/ipc-events/launch-recipe-registration.ts b/src/renderer/src/hooks/ipc-events/launch-recipe-registration.ts new file mode 100644 index 00000000000..da8b9777df3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/launch-recipe-registration.ts @@ -0,0 +1,16 @@ +import { useAppStore } from '../../store' +import type { PaneLaunchRecipe } from '../../../../shared/terminal-attachment-launch-result' +import { tryMakePaneKey } from './agent-status-routing' + +/** Record a pane's launch recipe so a later resume replays its flags. */ +export function registerLaunchRecipe(recipe: PaneLaunchRecipe): void { + const paneKey = tryMakePaneKey(recipe.tabId, recipe.leafId) + if (paneKey) { + useAppStore.getState().registerAgentLaunchConfig(paneKey, recipe.launchConfig, { + ...(recipe.launchAgent ? { agentType: recipe.launchAgent } : {}), + ...(recipe.launchToken ? { launchToken: recipe.launchToken } : {}), + tabId: recipe.tabId, + leafId: recipe.leafId + }) + } +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 8e361635d26..4d97e494a02 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -9,6 +9,7 @@ import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion' import { tryMakePaneKey } from './agent-status-routing' +import { registerLaunchRecipe } from './launch-recipe-registration' import { wasAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal' import { useAppStore } from '../../store' @@ -22,6 +23,7 @@ import { export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void { unsubs.push( + window.api.ui.onLaunchRecipe(registerLaunchRecipe), window.api.ui.onCreateTerminal( ({ requestId, @@ -175,14 +177,13 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v 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 - }) - } + registerLaunchRecipe({ + tabId: tab.id, + leafId, + launchConfig, + ...(launchToken ? { launchToken } : {}), + ...(launchAgent ? { launchAgent } : {}) + }) } else if (!splitFromLeafId && launchPaneKey) { store.clearAgentLaunchConfig(launchPaneKey) } diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 54f4f82ef79..bd97291951f 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -168,6 +168,7 @@ describe('useIpcEvents browser tab create routing', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index 263dd7d1035..7e2f4434858 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -139,6 +139,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -399,6 +400,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index 9fcb0a3f016..bbc71d7ab75 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -142,6 +142,7 @@ describe('useIpcEvents browser tab close routing', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -370,6 +371,7 @@ describe('useIpcEvents browser tab close routing', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -593,6 +595,7 @@ describe('useIpcEvents browser tab close routing', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 7b6abf4208d..a04cd3a11e3 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -151,6 +151,7 @@ describe('useIpcEvents updater integration', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index a66e9e52473..126699888f8 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -118,6 +118,7 @@ describe('useIpcEvents updater integration', () => { onPublishAgentLaunchTab: () => () => {}, onRevealTerminalPane: () => () => {}, onAgentLaunchPaneVerdict: () => () => {}, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index e6efb4dd794..6d574fcad10 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -145,6 +145,7 @@ function stubHost( listener = next return () => {} }, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: vi.fn() } diff --git a/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts b/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts index d7b6b7103c0..bff445c9043 100644 --- a/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts +++ b/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts @@ -59,6 +59,7 @@ beforeEach(() => { revealTerminal = listener return () => {} }, + onLaunchRecipe: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: vi.fn() } diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index e2d9b9de98e..0572cd454c8 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -263,6 +263,7 @@ export function createWebUiApi(): NonNullable['ui']> { replyTerminalCreate: () => {}, onPublishAgentLaunchTab: () => noopUnsubscribe, onAgentLaunchPaneVerdict: () => noopUnsubscribe, + onLaunchRecipe: () => noopUnsubscribe, replyAgentLaunchTabPublish: () => {}, onRevealTerminalPane: () => noopUnsubscribe, replyTerminalPaneReveal: () => {}, diff --git a/src/shared/terminal-attachment-launch-result.ts b/src/shared/terminal-attachment-launch-result.ts index 21a050d9b52..6b9952dc322 100644 --- a/src/shared/terminal-attachment-launch-result.ts +++ b/src/shared/terminal-attachment-launch-result.ts @@ -1,10 +1,17 @@ import { z } from 'zod' -import type { TerminalLaunchResult } from './agent-session-resume' +import type { SleepingAgentLaunchConfig, TerminalLaunchResult } from './agent-session-resume' import { isTuiAgent } from './tui-agent-config' import type { TuiAgent } from './tui-agent' import { ResumeProviderSessionParam } from './rpc-contract/terminal-unary-params' import { sleepingAgentLaunchConfigBaseSchema } from './workspace-session-sleeping-agents' +/** A launch recipe for a pane the window was not shown; the reveal otherwise carries it. */ +export type PaneLaunchRecipe = Pick & { + tabId: string + leafId: string + launchConfig: SleepingAgentLaunchConfig +} + export type TerminalAttachmentLaunchResult = TerminalLaunchResult & { ptyId: string incarnationId: string