fix: reconcile packaged terminal restoration

This commit is contained in:
Brennan Benson
2026-08-13 23:01:27 -07:00
parent ca0050913f
commit bfdf02ee39
25 changed files with 643 additions and 258 deletions
+5
View File
@@ -861,6 +861,11 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
})
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
await runtime?.prepareStructuredAgentSessionStartupRestoration()
})
ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () =>
recoverLegacyWorkerTerminalsForRendererStartup({
firstWindowStartupServicesReady,
@@ -5,7 +5,7 @@ import { OrcaRuntimeService } from './orca-runtime'
afterEach(() => setStructuredAgentSessionHost(null))
describe('structured session cold restoration', () => {
it('hydrates PTY inventory before recovering durable TUI owners and only runs once', async () => {
it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => {
const runtime = new OrcaRuntimeService()
const hydrate = vi.fn()
const refresh = vi.fn(async () => new Set<string>())
@@ -29,8 +29,10 @@ describe('structured session cold restoration', () => {
listSessionTabs: () => []
} as never)
await runtime.restoreStructuredAgentSessionTabs()
await runtime.restoreStructuredAgentSessionTabs()
const first = runtime.restoreStructuredAgentSessionTabs()
const second = runtime.restoreStructuredAgentSessionTabs()
expect(second).toBe(first)
await Promise.all([first, second])
expect(hydrate).toHaveBeenCalledWith('workspace-1', {
allowAttachedWindow: true,
@@ -39,8 +41,15 @@ describe('structured session cold restoration', () => {
expect(hydrate).toHaveBeenCalledWith()
expect(refresh).toHaveBeenCalledOnce()
expect(restoreReadableSessions).toHaveBeenCalledOnce()
expect(ensureHost).toHaveBeenCalledOnce()
expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan(
refresh.mock.invocationCallOrder[0] ?? Infinity
)
expect(refresh.mock.invocationCallOrder[0]).toBeLessThan(
restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity
)
expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan(
hydrate.mock.invocationCallOrder[0] ?? Infinity
)
})
})
+18 -3
View File
@@ -2828,6 +2828,7 @@ export class OrcaRuntimeService {
new TerminalFocusNavigationCoalescer<RuntimeTerminalFocus>()
private pendingMobileSessionPtyInventoryRefresh: Promise<Set<string> | null> | null = null
private structuredAgentSessionTabRestorePromise: Promise<void> | null = null
private structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
private leaves = new Map<string, RuntimeLeafRecord>()
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
// ptyId keeps active TUI redraws independent of the total open terminal count.
@@ -9804,7 +9805,24 @@ export class OrcaRuntimeService {
return this.structuredAgentSessionTabRestorePromise
}
prepareStructuredAgentSessionStartupRestoration(): Promise<void> {
this.structuredAgentSessionStartupRestorePromise ??=
this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => {
this.structuredAgentSessionStartupRestorePromise = null
throw error
})
return this.structuredAgentSessionStartupRestorePromise
}
private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise<void> {
// Durable agent records must exist before daemon inventory can be reconciled against them.
await this.ensureStructuredAgentSessionHost()
await this.refreshMobileSessionPtyRecords()
await getStructuredAgentSessionHost()?.restoreReadableSessions()
}
private async restoreStructuredAgentSessionTabsOnce(): Promise<void> {
await this.prepareStructuredAgentSessionStartupRestoration()
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
@@ -9812,10 +9830,7 @@ export class OrcaRuntimeService {
})
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
await this.refreshMobileSessionPtyRecords()
await this.ensureStructuredAgentSessionHost()
const host = getStructuredAgentSessionHost()
await host?.restoreReadableSessions()
for (const session of host?.listSessionTabs() ?? []) {
this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
}
+2
View File
@@ -951,6 +951,8 @@ export type AppApi = {
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise<void>
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
prepareTerminalStartupRestoration: () => Promise<void>
/** Reconciles legacy worker authority around persisted terminal reconnect. */
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise<void>
/** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */
+2
View File
@@ -525,6 +525,8 @@ const api = {
},
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
prepareTerminalStartupRestoration: (): Promise<void> =>
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'),
startupDiagnostic: (event: string, details?: Record<string, unknown>): Promise<void> =>
+22 -1
View File
@@ -113,7 +113,10 @@ import {
setRuntimeGraphSyncEnabled
} from './runtime/sync-runtime-graph'
import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync'
import { useLocalStructuredSessionTabsSync } from './runtime/local-structured-session-tabs-sync'
import {
restoreLocalStructuredSessionTabsOnce,
useLocalStructuredSessionTabsSync
} from './runtime/local-structured-session-tabs-sync'
import { StructuredAgentSessionStatusBridge } from './components/native-chat/StructuredAgentSessionStatusBridge'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost'
@@ -493,6 +496,7 @@ function App(): React.JSX.Element {
setSshConnectionState: s.setSshConnectionState,
hydratePersistedUI: s.hydratePersistedUI,
setHydrationSucceeded: s.setHydrationSucceeded,
setTerminalStartupRestorationReady: s.setTerminalStartupRestorationReady,
openModal: s.openModal,
closeModal: s.closeModal,
markFeatureTipsSeen: s.markFeatureTipsSeen,
@@ -1006,6 +1010,12 @@ function App(): React.JSX.Element {
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
})
await timeRendererStartupStep('prepare-terminal-startup-restoration', () =>
window.api.app.prepareTerminalStartupRestoration()
)
if (cancelled) {
return
}
// Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing.
// See docs/cmd-j-empty-query-ordering.md.
timeRendererStartupSyncStep('visit-timestamp-prune', () => {
@@ -1115,15 +1125,25 @@ function App(): React.JSX.Element {
await timeRendererStartupStep('reconnect-terminals', () =>
actions.reconnectPersistedTerminals(abortController.signal)
)
if (cancelled) {
return
}
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
await timeRendererStartupStep('project-structured-session-tabs', () =>
restoreLocalStructuredSessionTabsOnce()
)
if (cancelled) {
return
}
// Why here: reconnect just published restored PTY ids; sweeping them now
// re-offers stale Codex panes whose tabs never mount this session.
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
syncZoomCSSVar()
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
actions.setTerminalStartupRestorationReady(true)
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
@@ -1226,6 +1246,7 @@ function App(): React.JSX.Element {
pendingReconnectPtyIdByTabId: {}
})
}
actions.setTerminalStartupRestorationReady(true)
}
}
void actions.initGitHubCache()
@@ -321,6 +321,40 @@ describe('renderer startup runtime routing', () => {
expect(reconnectIndex).toBeGreaterThan(capabilityIndex)
})
it('orders packaged restoration before adoption, projection, and default creation', () => {
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const terminalSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
'utf8'
)
const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
const prepareIndex = appSource.indexOf(
"timeRendererStartupStep('prepare-terminal-startup-restoration'"
)
const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'")
const projectIndex = appSource.indexOf(
"timeRendererStartupStep('project-structured-session-tabs'"
)
const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)')
const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef')
const gateEnd = terminalSource.indexOf('const handleNewTab', gateStart)
const gateBlock = terminalSource.slice(gateStart, gateEnd)
const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)')
const createIndex = gateBlock.indexOf(
'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })'
)
expect(hydrateIndex).toBeGreaterThanOrEqual(0)
expect(hydrateIndex).toBeLessThan(prepareIndex)
expect(prepareIndex).toBeLessThan(reconnectIndex)
expect(reconnectIndex).toBeLessThan(projectIndex)
expect(projectIndex).toBeLessThan(readyIndex)
expect(gateBlock).toContain('terminalStartupRestorationReady')
expect(gateIndex).toBeGreaterThanOrEqual(0)
expect(gateIndex).toBeLessThan(createIndex)
expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome === 'blocked'")
})
it('does not load the terminal workbench on the no-workspace landing path', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
+34 -23
View File
@@ -342,6 +342,7 @@ function Terminal(): React.JSX.Element | null {
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const terminalStartupRestorationReady = useAppStore((s) => s.terminalStartupRestorationReady)
const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const openFiles = useAppStore((s) => s.openFiles)
@@ -1465,40 +1466,50 @@ function Terminal(): React.JSX.Element | null {
])
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
// Auto-create first tab when worktree activates
const startupActivationGateWorktreeIdsRef = useRef(new Set<string>())
useEffect(() => {
if (!workspaceSessionReady) {
return
}
if (!activeWorktreeId) {
if (
!workspaceSessionReady ||
!terminalStartupRestorationReady ||
!hydrationSucceeded ||
!activeWorktreeId
) {
return
}
// Why: host session-tabs are authoritative in the paired web client; a local fallback races the host's initial terminal and duplicates tabs.
if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) {
return
}
// Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab.
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
return
}
// Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps).
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
}, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel])
const startupActivationGateWorktreeIdsRef = useRef(new Set<string>())
useEffect(() => {
if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) {
return
}
if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) {
return
}
startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId)
// Why: startup bypasses activateAndRevealWorktree, but must still adopt daemon PTYs before resuming persisted agents.
void gateWorktreeAgentActivation(activeWorktreeId)
}, [activeWorktreeId, hydrationSucceeded, workspaceSessionReady])
let cancelled = false
void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => {
if (
cancelled ||
outcome === 'blocked' ||
useAppStore.getState().activeWorktreeId !== activeWorktreeId
) {
return
}
// Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell.
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
if (shouldAutoCreateInitialTerminal(renderableTabCount)) {
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
}
})
return () => {
cancelled = true
}
}, [
activeWorktreeId,
createTab,
hydrationSucceeded,
reconcileWorktreeTabModel,
terminalStartupRestorationReady,
workspaceSessionReady
])
const handleNewTab = useCallback(
(shellOverride?: string) => {
@@ -763,11 +763,11 @@ function TerminalPane(
() => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId]
)
const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit)
useEffect(() => {
const settleTabStartupCommand = useCallback(() => {
if (startup) {
consumeTabStartupCommand(tabId)
consumeTabStartupCommand(tabId, startup)
}
}, [startup, tabId, consumeTabStartupCommand])
}, [consumeTabStartupCommand, startup, tabId])
useLayoutEffect(() => {
if (isVisible && shouldMeasureHiddenStartup) {
@@ -1418,6 +1418,7 @@ function TerminalPane(
setCacheTimerStartedAt,
syncPanePtyLayoutBinding,
clearExitedPanePtyLayoutBinding,
onStartupBound: settleTabStartupCommand,
setTabPaneExpanded,
setTabCanExpandPane,
setExpandedPane,
@@ -98,6 +98,8 @@ export type PtyConnectionDeps = {
setCacheTimerStartedAt: (key: string, ts: number | null) => void
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
/** Settles the captured one-shot startup only after this pane owns a concrete PTY. */
onStartupBound?: () => void
deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void
/** Records a DECSET 2031 subscription answered from main's
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
@@ -4005,6 +4005,34 @@ describe('connectPanePty', () => {
)
})
it('settles a queued startup only after the pane binds its spawned PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-resume')
transportFactoryQueue.push(transport)
const onStartupBound = vi.fn()
const startup = {
command: "codex 'resume' 'codex-session-1'",
resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const
}
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ startup, onStartupBound }) as never
)
expect(onStartupBound).not.toHaveBeenCalled()
expect(createdTransportOptions[0]).toMatchObject(startup)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.('pty-resume')
onPtySpawn?.('pty-resume')
expect(onStartupBound).toHaveBeenCalledTimes(1)
})
it('seeds a working status for Command Code startup prompts after spawn', async () => {
const { connectPanePty } = await import('./pty-connection')
const sshPtyId = toAppSshPtyId('ssh-a', 'pty-command-code')
@@ -1202,6 +1202,7 @@ export function connectPanePty(
// mutation does not propagate back.
const paneStartup = deps.startup ?? null
deps.startup = undefined
let startupPtyBound = false
// 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.
@@ -3023,6 +3024,10 @@ export function connectPanePty(
deps.updateTabPtyId(deps.tabId, ptyId)
}
}
if (paneStartup && !startupPtyBound) {
startupPtyBound = true
deps.onStartupBound?.()
}
if (options.seedInitialAgentStatus) {
applyInitialAgentStatus()
}
@@ -317,6 +317,8 @@ type UseTerminalPaneLifecycleDeps = {
setCacheTimerStartedAt: (key: string, ts: number | null) => void
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
/** Settles the captured one-shot startup only after a pane owns a concrete PTY. */
onStartupBound?: () => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setExpandedPane: (paneId: number | null) => void
@@ -640,6 +642,7 @@ export function useTerminalPaneLifecycle({
setCacheTimerStartedAt,
syncPanePtyLayoutBinding,
clearExitedPanePtyLayoutBinding,
onStartupBound,
setTabPaneExpanded,
setTabCanExpandPane,
setExpandedPane,
@@ -871,6 +874,7 @@ export function useTerminalPaneLifecycle({
setCacheTimerStartedAt,
syncPanePtyLayoutBinding,
clearExitedPanePtyLayoutBinding,
onStartupBound,
deferPtyInput: (paneId, data, forward) => {
const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression
if (!suppression) {
@@ -34,6 +34,48 @@ function makeTerminalTab(id: string): Record<string, unknown> {
}
describe('resumeSleepingAgentSessionsForWorktree replay protection', () => {
it('publishes the resume command and ownership claim with the first visible tab state', () => {
const record = makeRecord()
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
let firstVisible:
| {
command: string | undefined
claim: unknown
layout: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId'][string]
}
| undefined
const unsubscribe = useAppStore.subscribe((state) => {
const tab = state.tabsByWorktree['wt-1']?.[0]
if (tab && !firstVisible) {
firstVisible = {
command: state.pendingStartupByTabId[tab.id]?.command,
claim: state.automaticAgentResumeClaimsByTabId[tab.id],
layout: state.terminalLayoutsByTabId[tab.id]
}
}
})
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
unsubscribe()
expect(firstVisible?.command).toContain('resume')
expect(firstVisible?.command).toContain(record.providerSession.id)
expect(firstVisible?.claim).toEqual({
worktreeId: record.worktreeId,
launchAgent: record.agent,
providerSession: record.providerSession
})
expect(firstVisible?.layout).toMatchObject({
root: { type: 'leaf', leafId: expect.any(String) },
activeLeafId: expect.any(String)
})
const root = firstVisible?.layout?.root
expect(firstVisible?.layout?.activeLeafId).toBe(root?.type === 'leaf' ? root.leafId : undefined)
})
it('stores provider-session metadata in the queued startup and runtime claim', () => {
const record = makeRecord()
useAppStore.setState({
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
const mockCreateTab = vi.fn()
const mockQueueTabStartupCommand = vi.fn()
const store = {
settings: {
@@ -47,7 +46,6 @@ const store = {
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
createTab: mockCreateTab,
queueTabStartupCommand: mockQueueTabStartupCommand,
claimAutomaticAgentResume: vi.fn(),
clearSleepingAgentSession: vi.fn(),
setActiveTabType: vi.fn(),
@@ -83,10 +81,10 @@ const record: SleepingAgentSessionRecord = {
async function launch(): Promise<string | undefined> {
const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch')
launchSleepingAgentSession(record)
const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as
| { command: string }
const options = mockCreateTab.mock.calls.at(-1)?.[3] as
| { pendingStartup?: { command: string } }
| undefined
return queued?.command
return options?.pendingStartup?.command
}
describe('launchSleepingAgentSession Windows shell quoting', () => {
@@ -100,30 +100,30 @@ export function launchSleepingAgentSession(
const tab = state.createTab(record.worktreeId, undefined, undefined, {
launchAgent: record.agent,
pendingStartup: {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
resumeProviderSession: record.providerSession,
launchAgent: record.agent,
...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}),
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
showSessionRestoredBanner: true,
telemetry: {
agent_kind: tuiAgentToAgentKind(record.agent),
launch_source: 'sidebar',
request_kind: 'resume'
}
},
automaticResumeClaim: {
worktreeId: record.worktreeId,
launchAgent: record.agent,
providerSession: record.providerSession
},
...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {})
})
state.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
resumeProviderSession: record.providerSession,
launchAgent: record.agent,
...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}),
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
showSessionRestoredBanner: true,
telemetry: {
agent_kind: tuiAgentToAgentKind(record.agent),
launch_source: 'sidebar',
request_kind: 'resume'
}
})
state.claimAutomaticAgentResume(tab.id, {
worktreeId: record.worktreeId,
launchAgent: record.agent,
providerSession: record.providerSession
})
state.clearSleepingAgentSession(record.paneKey)
if (!options?.suppressNavigation) {
state.setActiveTabType('terminal')
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { PtyListedSession } from '../../../shared/pty-listed-session'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { TerminalTab } from '../../../shared/types'
import type { TerminalSlice } from '@/store/slices/terminals'
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
const WORKTREE_ID = 'repo::/worktree'
@@ -72,16 +74,41 @@ function testDeps(args: {
structured?: boolean
resumeCount?: number
}) {
const createTab = vi.fn()
const resume = vi.fn(() => args.resumeCount ?? 1)
const sleeping = args.sleeping ?? []
const ptyIdsByTabId: Record<string, string[]> = {}
const tabsByWorktree: Record<string, TerminalTab[]> = { [WORKTREE_ID]: [] }
let createdCount = 0
const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => {
createdCount += 1
const id = options?.id ?? `created-${createdCount}`
const tab: TerminalTab = {
id,
ptyId: options?.initialPtyId ?? null,
worktreeId,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: tabsByWorktree[worktreeId]?.length ?? 0,
createdAt: 1
}
tabsByWorktree[worktreeId] ??= []
tabsByWorktree[worktreeId].push(tab)
ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : []
return tab
})
const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => {
ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])]
})
const store = {
createTab,
ptyIdsByTabId,
tabsByWorktree,
sleepingAgentSessionsByPaneKey: Object.fromEntries(
sleeping.map((record) => [record.paneKey, record])
),
updateTabPtyId,
replaceTerminalLayoutPanePtyId: vi.fn(),
terminalLayoutsByTabId: Object.fromEntries(
sleeping.map((record) => {
const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1)
@@ -158,6 +185,18 @@ describe('worktree agent activation gate', () => {
expect(resume).not.toHaveBeenCalled()
})
it('reads structured ownership before adopting daemon PTYs', async () => {
const ptyId = `${WORKTREE_ID}@@live-pty`
const { deps } = testDeps({ sessions: [listed(ptyId)] })
const hasStructuredSession = vi.fn(async () => false)
await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan(
deps.listSessions.mock.invocationCallOrder[0] ?? Infinity
)
})
it('blocks automatic resume when packaged restore never becomes ready', async () => {
const { deps, createTab, resume } = testDeps({})
@@ -262,11 +301,17 @@ describe('worktree agent activation gate', () => {
})
})
it('uses the exact runtime surface while packaged renderer PTY bindings are absent', async () => {
it('adopts the exact structured TUI surface without creating a duplicate tab', async () => {
const tabId = 'structured-agent-session-live-session'
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
const livePtyId = `${WORKTREE_ID}@@live-agent`
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] })
const { deps, createTab, resume } = testDeps({
sessions: [listed(livePtyId)],
sleeping: [live],
resumeCount: 0
})
const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345'
await expect(
runWorktreeAgentActivationGate(WORKTREE_ID, {
@@ -279,18 +324,25 @@ describe('worktree agent activation gate', () => {
{
owner: 'tui',
terminal: {
paneKey:
'3359f7c8-9bd8-4931-8104-52b6bdbd108d:2659ee80-d3fc-454f-b4ea-0638de1ae345',
paneKey: `${ownerTabId}:${ownerLeafId}`,
ptyId: livePtyId,
tabId: '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
tabId: ownerTabId
}
}
]
])
})
})
).resolves.toBe('resumed')
).resolves.toBe('adopted')
expect(createTab).toHaveBeenCalledOnce()
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
id: ownerTabId,
initialLeafId: ownerLeafId,
initialPtyId: livePtyId,
activate: false,
recordInteraction: false
})
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
})
@@ -307,7 +359,7 @@ describe('worktree agent activation gate', () => {
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
})
it('does not claim a structured owner whose PTY is absent from live inventory', async () => {
it('blocks when a structured TUI owner is absent from live inventory', async () => {
const tabId = 'structured-agent-session-live-session'
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
const livePtyId = `${WORKTREE_ID}@@live-agent`
@@ -333,12 +385,12 @@ describe('worktree agent activation gate', () => {
])
})
})
).resolves.toBe('resumed')
).resolves.toBe('blocked')
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
expect(resume).not.toHaveBeenCalled()
})
it('does not claim an owner for a different structured session tab', async () => {
it('uses the restored owner surface even when its tab predates structured naming', async () => {
const tabId = 'structured-agent-session-other-session'
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
const livePtyId = `${WORKTREE_ID}@@live-agent`
@@ -362,7 +414,9 @@ describe('worktree agent activation gate', () => {
})
).resolves.toBe('resumed')
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
})
})
it('suppresses the exact structured session when native already owns it', async () => {
@@ -1,7 +1,6 @@
import { useAppStore } from '@/store'
import type { PtyListedSession } from '../../../shared/pty-listed-session'
import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
@@ -10,27 +9,24 @@ import {
type ResumeSleepingAgentSessionsOptions
} from './resume-sleeping-agent-session'
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption'
import {
readWorktreeStructuredActivationInventory,
type StructuredActivationInventory
} from './worktree-agent-structured-inventory'
type ActivationStore = Pick<
ReturnType<typeof useAppStore.getState>,
| 'createTab'
| 'ptyIdsByTabId'
| 'sleepingAgentSessionsByPaneKey'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'unifiedTabsByWorktree'
| 'updateTabPtyId'
| 'replaceTerminalLayoutPanePtyId'
>
type StructuredActivationInventory = {
snapshot: RuntimeMobileSessionTabsResult
ownerBySessionId: ReadonlyMap<
string,
{
owner: 'native' | 'tui'
terminal?: { paneKey: string; ptyId: string; tabId: string }
}
>
}
type ActivationGateDeps = {
getState: () => ActivationStore
awaitReady?: () => Promise<boolean>
@@ -45,7 +41,11 @@ const inFlightByWorktreeId = new Map<string, Promise<WorktreeAgentActivationOutc
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
function waitForWorkspaceSessionReady(): Promise<boolean> {
if (useAppStore.getState().workspaceSessionReady) {
const isReady = () => {
const state = useAppStore.getState()
return state.workspaceSessionReady && state.terminalStartupRestorationReady
}
if (isReady()) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
@@ -55,16 +55,13 @@ function waitForWorkspaceSessionReady(): Promise<boolean> {
unsubscribe?.()
resolve(ready)
}
const timeout = setTimeout(
() => settle(useAppStore.getState().workspaceSessionReady),
WORKSPACE_SESSION_READY_TIMEOUT_MS
)
const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS)
unsubscribe = useAppStore.subscribe((state) => {
if (state.workspaceSessionReady) {
if (state.workspaceSessionReady && state.terminalStartupRestorationReady) {
settle(true)
}
})
if (useAppStore.getState().workspaceSessionReady) {
if (isReady()) {
settle(true)
}
})
@@ -152,6 +149,42 @@ export async function runWorktreeAgentActivationGate(
} catch {
return 'blocked'
}
let structured = false
let structuredInventory: StructuredActivationInventory | null = null
try {
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
structuredInventory =
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
structured = Boolean(
hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
)
} catch {
return 'blocked'
}
const structuredTabs = structuredInventory?.snapshot.tabs.filter(
(tab) => tab.type === 'agent-session'
)
if (
structuredTabs?.some((tab) => {
const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId)
return (
!owner ||
(owner.owner === 'tui' &&
(!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId))
)
})
) {
return 'blocked'
}
if (
structured &&
!structuredInventory &&
workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)
) {
return 'blocked'
}
let sessions: PtyListedSession[]
try {
sessions = await deps.listSessions()
@@ -164,6 +197,18 @@ export async function runWorktreeAgentActivationGate(
sessionBelongsToWorkspace(session.id, worktreeId)
)
const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id))
for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) {
if (owner.owner !== 'tui') {
continue
}
if (
!owner.terminal ||
!liveWorkspacePtyIds.has(owner.terminal.ptyId) ||
!bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal)
) {
return 'blocked'
}
}
if (liveWorkspaceSessions.length > 0) {
for (const session of liveWorkspaceSessions) {
const store = deps.getState()
@@ -181,20 +226,8 @@ export async function runWorktreeAgentActivationGate(
}
}
let structured = false
let structuredInventory: StructuredActivationInventory | null = null
try {
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
structuredInventory =
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
structured = Boolean(
hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
)
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
return 'structured'
}
} catch {
return 'blocked'
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
return 'structured'
}
const launched = deps.resume(worktreeId, {
skipClaimKeys: liveSleepingAgentClaimKeys(
@@ -225,76 +258,7 @@ export function gateWorktreeAgentActivation(
awaitReady: waitForWorkspaceSessionReady,
listSessions: () =>
typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(),
hasStructuredSession: async (candidate) => {
if (typeof window === 'undefined') {
return false
}
const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} })
if (!response.ok) {
throw new Error('structured session inventory unavailable')
}
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
const snapshot = (result.snapshots ?? []).find(
(candidateSnapshot) =>
candidateSnapshot.worktree === candidate &&
candidateSnapshot.tabs.some((tab) => tab.type === 'agent-session')
)
if (!snapshot) {
return false
}
const ownerBySessionId = new Map<
string,
{
owner: 'native' | 'tui'
terminal?: { paneKey: string; ptyId: string; tabId: string }
}
>()
await Promise.all(
snapshot.tabs.flatMap((tab) =>
tab.type === 'agent-session'
? [
window.api.runtime
.call({
method: 'agentSession.handoffStatus',
params: { sessionId: tab.sessionId }
})
.then((statusResponse) => {
if (!statusResponse.ok) {
return
}
const status = statusResponse.result as {
owner?: unknown
terminal?: {
paneKey?: unknown
ptyId?: unknown
tabId?: unknown
}
}
if (status.owner === 'native') {
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
} else if (
status.owner === 'tui' &&
typeof status.terminal?.paneKey === 'string' &&
typeof status.terminal?.ptyId === 'string' &&
status.terminal.ptyId.length > 0 &&
typeof status.terminal.tabId === 'string'
) {
ownerBySessionId.set(tab.sessionId, {
owner: 'tui',
terminal: {
paneKey: status.terminal.paneKey,
ptyId: status.terminal.ptyId,
tabId: status.terminal.tabId
}
})
}
})
]
: []
)
)
return { snapshot, ownerBySessionId }
},
hasStructuredSession: readWorktreeStructuredActivationInventory,
resume: resumeSleepingAgentSessionsForWorktree
}).finally(() => {
if (inFlightByWorktreeId.get(worktreeId) === gate) {
@@ -0,0 +1,65 @@
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { useAppStore } from '@/store'
type LiveSurfaceAdoptionStore = Pick<
ReturnType<typeof useAppStore.getState>,
| 'createTab'
| 'ptyIdsByTabId'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'updateTabPtyId'
| 'replaceTerminalLayoutPanePtyId'
>
function layoutContainsLeaf(
root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'],
leafId: string
): boolean {
if (!root) {
return false
}
return root.type === 'leaf'
? root.leafId === leafId
: layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId)
}
export function bindLivePtyToExactSurface(
store: LiveSurfaceAdoptionStore,
worktreeId: string,
terminal: { paneKey: string; ptyId: string; tabId: string }
): boolean {
const pane = parsePaneKey(terminal.paneKey)
if (!pane || pane.tabId !== terminal.tabId) {
return false
}
const ownerEntries = Object.entries(store.tabsByWorktree).flatMap(([ownerWorktreeId, tabs]) =>
tabs.filter((tab) => tab.id === terminal.tabId).map((tab) => ({ ownerWorktreeId, tab }))
)
const competingBinding = Object.entries(store.ptyIdsByTabId).some(
([tabId, ptyIds]) => tabId !== terminal.tabId && ptyIds.includes(terminal.ptyId)
)
if (ownerEntries.length > 1 || competingBinding) {
return false
}
const existing = ownerEntries[0]
if (existing) {
const layout = store.terminalLayoutsByTabId[terminal.tabId]
if (
existing.ownerWorktreeId !== worktreeId ||
!layoutContainsLeaf(layout?.root ?? null, pane.leafId)
) {
return false
}
store.updateTabPtyId(terminal.tabId, terminal.ptyId)
store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId)
return true
}
const created = store.createTab(worktreeId, undefined, undefined, {
id: terminal.tabId,
initialLeafId: pane.leafId,
initialPtyId: terminal.ptyId,
activate: false,
recordInteraction: false
})
return created.id === terminal.tabId
}
@@ -0,0 +1,81 @@
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
export type StructuredActivationInventory = {
snapshot: RuntimeMobileSessionTabsResult
ownerBySessionId: ReadonlyMap<
string,
{
owner: 'native' | 'tui'
terminal?: { paneKey: string; ptyId: string; tabId: string }
}
>
}
export async function readWorktreeStructuredActivationInventory(
worktreeId: string
): Promise<false | StructuredActivationInventory> {
if (typeof window === 'undefined') {
return false
}
const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} })
if (!response.ok) {
throw new Error('structured session inventory unavailable')
}
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
const snapshot = (result.snapshots ?? []).find(
(candidate) =>
candidate.worktree === worktreeId &&
candidate.tabs.some((tab) => tab.type === 'agent-session')
)
if (!snapshot) {
return false
}
const ownerBySessionId = new Map<
string,
{
owner: 'native' | 'tui'
terminal?: { paneKey: string; ptyId: string; tabId: string }
}
>()
await Promise.all(
snapshot.tabs.flatMap((tab) =>
tab.type === 'agent-session'
? [
window.api.runtime
.call({
method: 'agentSession.handoffStatus',
params: { sessionId: tab.sessionId }
})
.then((statusResponse) => {
if (!statusResponse.ok) {
return
}
const status = statusResponse.result as {
owner?: unknown
terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
}
if (status.owner === 'native') {
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
} else if (
status.owner === 'tui' &&
typeof status.terminal?.paneKey === 'string' &&
typeof status.terminal?.ptyId === 'string' &&
status.terminal.ptyId.length > 0 &&
typeof status.terminal.tabId === 'string'
) {
ownerBySessionId.set(tab.sessionId, {
owner: 'tui',
terminal: {
paneKey: status.terminal.paneKey,
ptyId: status.terminal.ptyId,
tabId: status.terminal.tabId
}
})
}
})
]
: []
)
)
return { snapshot, ownerBySessionId }
}
@@ -31,6 +31,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
activeRepoId: 'repo-1',
activeView: 'terminal',
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
@@ -22,6 +22,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
activeRepoId: 'repo-1',
activeView: 'terminal',
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
@@ -57,7 +58,11 @@ afterEach(() => {
describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => {
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
useAppStore.setState({ ...baseState(worktree), workspaceSessionReady: false })
useAppStore.setState({
...baseState(worktree),
workspaceSessionReady: false,
terminalStartupRestorationReady: false
})
const paneKey = 'packaged-restart-pane:0'
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
@@ -96,7 +101,10 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
await Promise.resolve()
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
useAppStore.setState({ workspaceSessionReady: true })
useAppStore.setState({
workspaceSessionReady: true,
terminalStartupRestorationReady: true
})
await gate
const restored = useAppStore.getState()
expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1)
@@ -5,6 +5,7 @@ import { useAppStore } from '../store'
import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
let localStructuredSessionTabsRestorePromise: Promise<void> | null = null
type SessionTabsEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
@@ -54,6 +55,23 @@ export function applyStructuredSessionTabSnapshots(
})
}
export function restoreLocalStructuredSessionTabsOnce(): Promise<void> {
localStructuredSessionTabsRestorePromise ??= window.api.runtime
.call({ method: 'session.tabs.listAll', params: {} })
.then((response) => {
if (!response.ok) {
throw new Error('structured session inventory unavailable')
}
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
applyStructuredSessionTabSnapshots(result.snapshots ?? [])
})
.catch((error) => {
localStructuredSessionTabsRestorePromise = null
throw error
})
return localStructuredSessionTabsRestorePromise
}
async function startLocalStructuredSessionTabsSync(args: {
isDisposed: () => boolean
setUnsubscribe: (unsubscribe: () => void) => void
@@ -63,12 +81,10 @@ async function startLocalStructuredSessionTabsSync(args: {
return
}
const supported = status.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
void window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }).then((response) => {
if (!args.isDisposed() && response.ok) {
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
applyStructuredSessionTabSnapshots(result.snapshots ?? [])
}
})
await restoreLocalStructuredSessionTabsOnce()
if (args.isDisposed()) {
return
}
if (!supported) {
return
}
@@ -94,7 +110,9 @@ async function startLocalStructuredSessionTabsSync(args: {
}
export function useLocalStructuredSessionTabsSync(): void {
const ready = useAppStore((state) => state.workspaceSessionReady)
const ready = useAppStore(
(state) => state.workspaceSessionReady && state.terminalStartupRestorationReady
)
useEffect(() => {
if (!ready) {
return
+88 -74
View File
@@ -57,7 +57,11 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
import {
clearTransientTerminalState,
emptyLayoutSnapshot,
singlePaneLayoutSnapshot
} from './terminal-helpers'
import {
collectReleasedLeafIds,
hydrateWorkspaceTerminalRows,
@@ -529,6 +533,40 @@ export type AutomaticAgentResumeClaim = {
providerSession: AgentProviderSessionMetadata
}
type TabStartupCommand = {
command: string
/** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
/** 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. */
showSessionRestoredBanner?: boolean
/** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */
telemetry?: AgentStartedTelemetry
}
function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand {
// Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token.
const launchToken = startup.launchConfig
? (startup.launchToken ?? createBrowserUuid())
: undefined
return {
...startup,
...(launchToken ? { launchToken } : {})
}
}
export type CodexRestartNotice = {
previousAccountLabel: string
nextAccountLabel: string
@@ -595,31 +633,7 @@ export type TerminalSlice = {
resolution: Pick<NativeChatLaunchDraft, 'createdAt' | 'text'>
) => void
clearNativeChatLaunchDraft: (tabId: string) => void
pendingStartupByTabId: Record<
string,
{
command: string
/** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
/** 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. */
showSessionRestoredBanner?: boolean
/** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */
telemetry?: AgentStartedTelemetry
}
>
pendingStartupByTabId: Record<string, TabStartupCommand>
pendingInitialCwdByTabId: Record<string, string>
/** Queued setup-split requests; TerminalPane splits and runs the command in a new pane so the main terminal stays immediately interactive. */
pendingSetupSplitByTabId: Record<
@@ -630,6 +644,9 @@ export type TerminalSlice = {
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
tabBarOrderByWorktree: Record<string, string[]>
workspaceSessionReady: boolean
/** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */
terminalStartupRestorationReady: boolean
setTerminalStartupRestorationReady: (value: boolean) => void
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
markDefaultTerminalTabsApplied: (worktreeId: string) => void
@@ -660,6 +677,12 @@ export type TerminalSlice = {
options?: {
pendingActivationSpawn?: boolean
initialPtyId?: string
/** Stable leaf identity for adopting an already-live pane without changing its pane key. */
initialLeafId?: string
/** Published atomically with the tab so its first mount cannot spawn a bare shell. */
pendingStartup?: TabStartupCommand
/** Published atomically with pendingStartup for automatic resume ownership. */
automaticResumeClaim?: AutomaticAgentResumeClaim
activate?: boolean
recordInteraction?: boolean
/** Pre-allocated tab id (main mints it for CLI/runtime PTYs with a baked pane key); minted fresh on omit or cross-worktree collision. */
@@ -776,45 +799,13 @@ export type TerminalSlice = {
sourceTabId: string
targetTabId: string
}) => void
queueTabStartupCommand: (
tabId: string,
startup: {
command: string
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void
queueTabStartupCommand: (tabId: string, startup: TabStartupCommand) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
consumeTabInitialCwd: (tabId: string) => string | null
consumeTabStartupCommand: (tabId: string) => {
command: string
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
} | null
consumeTabStartupCommand: (
tabId: string,
expected?: TabStartupCommand
) => TabStartupCommand | null
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
@@ -1106,6 +1097,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
nativeChatLaunchDraftByTabId: {},
tabBarOrderByWorktree: {},
workspaceSessionReady: false,
terminalStartupRestorationReady: false,
setTerminalStartupRestorationReady: (value) => {
set({ terminalStartupRestorationReady: value })
},
restoredRuntimeHostIdByWorkspaceSessionKey: {},
defaultTerminalTabsAppliedByWorktreeId: {},
markDefaultTerminalTabsApplied: (worktreeId) =>
@@ -1361,6 +1356,15 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
)
}
const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid()
const requestedInitialLeafId =
options?.initialLeafId && isTerminalLeafId(options.initialLeafId)
? options.initialLeafId
: undefined
// Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation.
const initialLeafId =
options?.initialPtyId || options?.pendingStartup
? (requestedInitialLeafId ?? createBrowserUuid())
: undefined
const shouldActivate = options?.activate !== false
const nextOrdinal = getNextTerminalOrdinal(existing)
const defaultTitle = `Terminal ${nextOrdinal}`
@@ -1522,9 +1526,23 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...orphanCleanupPatch.ptyIdsByTabId,
[tab.id]: options?.initialPtyId ? [options.initialPtyId] : []
},
pendingStartupByTabId: options?.pendingStartup
? {
...orphanCleanupPatch.pendingStartupByTabId,
[tab.id]: normalizeTabStartupCommand(options.pendingStartup)
}
: orphanCleanupPatch.pendingStartupByTabId,
automaticAgentResumeClaimsByTabId: options?.automaticResumeClaim
? {
...orphanCleanupPatch.automaticAgentResumeClaimsByTabId,
[tab.id]: options.automaticResumeClaim
}
: orphanCleanupPatch.automaticAgentResumeClaimsByTabId,
terminalLayoutsByTabId: {
...orphanCleanupPatch.terminalLayoutsByTabId,
[tab.id]: emptyLayoutSnapshot()
[tab.id]: initialLeafId
? singlePaneLayoutSnapshot(initialLeafId, options?.initialPtyId)
: emptyLayoutSnapshot()
}
}
})
@@ -3795,17 +3813,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
},
queueTabStartupCommand: (tabId, startup) => {
// Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token.
const launchToken = startup.launchConfig
? (startup.launchToken ?? createBrowserUuid())
: undefined
set((s) => ({
pendingStartupByTabId: {
...s.pendingStartupByTabId,
[tabId]: {
...startup,
...(launchToken ? { launchToken } : {})
}
[tabId]: normalizeTabStartupCommand(startup)
}
}))
},
@@ -3832,13 +3843,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
return pending
},
consumeTabStartupCommand: (tabId) => {
consumeTabStartupCommand: (tabId, expected) => {
const pending = get().pendingStartupByTabId[tabId]
if (!pending) {
if (!pending || (expected && pending !== expected)) {
return null
}
set((s) => {
if (s.pendingStartupByTabId[tabId] !== pending) {
return {}
}
const next = { ...s.pendingStartupByTabId }
delete next[tabId]
return { pendingStartupByTabId: next }
+1
View File
@@ -571,6 +571,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
// Staging already wrote through to browser storage, so there is nothing left to join.
awaitBeforeUnloadCheckpoint: () => Promise.resolve(),
awaitFirstWindowStartupServices: () => Promise.resolve(),
prepareTerminalStartupRestoration: () => Promise.resolve(),
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(),
startupDiagnostic: () => Promise.resolve(),
getKeyboardInputSourceId: () => Promise.resolve(null),