diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 36a2be361db..84742173ae6 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -229,7 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView' import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError' -import { sendMobileStructuredTuiMessage } from '../../../../src/session/mobile-structured-tui-send' +import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send' import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry' import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create' import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' @@ -4660,33 +4660,19 @@ export default function SessionScreen() { } return accepted }} - onTuiSend={async (text, restored) => { - const handoff = structuredSessionEntry.session.handoff - const terminal = - handoff?.owner === 'tui' && handoff.phase === 'idle' - ? handoff.terminal?.handle - : null - if (!client || connState !== 'connected' || !terminal) { - showToast('Message not sent (disconnected)', 1800) - return false - } - const outcome = await sendMobileStructuredTuiMessage({ + onTuiSend={(text, restored) => + mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({ client, - terminal, + connected: connState === 'connected', + agent: activeStructuredTab.agent, + handoff: structuredSessionEntry.session.handoff, deviceToken: deviceTokenRef.current, text, - attachments: [...restored, ...structuredSessionEntry.attachments.attachments] + attachments: [...restored, ...structuredSessionEntry.attachments.attachments], + onAccepted: structuredSessionEntry.attachments.clear, + onToast: showToast }) - if (outcome === 'rejected') { - showToast('Message not sent', 1800) - return false - } - structuredSessionEntry.attachments.clear() - if (outcome === 'unknown') { - showToast('Delivery unconfirmed — check chat before retrying', 2400) - } - return true - }} + } onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit} onRetry={structuredSessionEntry.writes.retry} onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt} diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index 0379071e2a0..042f63ddd6c 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -73,7 +73,7 @@ type Props = { onRequestHandoff: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => Promise } @@ -350,6 +350,10 @@ function MobileStructuredHandoffBanner(props: { void props.onRequest('to-native', 'after-turn')}> Return to chat + ) : status.phase === 'failed' && status.direction && status.error?.canRetryProof ? ( + void props.onRequest(status.direction!, 'now', 'recover')}> + Retry proof + ) : status.phase === 'failed' && status.direction && status.error?.recoverableOwner !== 'none' ? ( diff --git a/mobile/src/session/mobile-structured-tui-send.test.ts b/mobile/src/session/mobile-structured-tui-send.test.ts index 2a440ddc4f0..5c7dc5fe262 100644 --- a/mobile/src/session/mobile-structured-tui-send.test.ts +++ b/mobile/src/session/mobile-structured-tui-send.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' -import { sendMobileStructuredTuiMessage } from './mobile-structured-tui-send' +import { + sendMobileStructuredTuiComposerMessage, + sendMobileStructuredTuiMessage +} from './mobile-structured-tui-send' function acceptingClient(): RpcClient { return { @@ -29,6 +32,7 @@ describe('sendMobileStructuredTuiMessage', () => { sendMobileStructuredTuiMessage({ client, terminal: 'term-tui', + agent: 'codex', deviceToken: 'mobile-1', text: 'hello', attachments: [] @@ -54,6 +58,7 @@ describe('sendMobileStructuredTuiMessage', () => { const sending = sendMobileStructuredTuiMessage({ client, terminal: 'term-tui', + agent: 'codex', deviceToken: null, text: '/model', attachments: [] @@ -75,4 +80,52 @@ describe('sendMobileStructuredTuiMessage', () => { ) ).toBe(true) }) + + it('types Claude-only slash commands through the provider command path', async () => { + vi.useFakeTimers() + const client = acceptingClient() + + const sending = sendMobileStructuredTuiMessage({ + client, + terminal: 'term-claude', + agent: 'claude', + deviceToken: null, + text: '/clear', + attachments: [] + }) + await vi.runAllTimersAsync() + await expect(sending).resolves.toBe('accepted') + + expect( + vi + .mocked(client.sendRequest) + .mock.calls.map(([, params]) => (params as { text: string }).text) + ).toEqual(['\x15', '/', 'c', 'l', 'e', 'a', 'r', '\r']) + }) + + it('settles the composer only after its proven TUI owner accepts the send', async () => { + const onAccepted = vi.fn() + + await expect( + sendMobileStructuredTuiComposerMessage({ + client: acceptingClient(), + connected: true, + agent: 'claude', + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' } + }, + deviceToken: null, + text: 'hello', + attachments: [], + onAccepted, + onToast: vi.fn() + }) + ).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledOnce() + }) }) diff --git a/mobile/src/session/mobile-structured-tui-send.ts b/mobile/src/session/mobile-structured-tui-send.ts index 8c6bb51b458..9adc14093f4 100644 --- a/mobile/src/session/mobile-structured-tui-send.ts +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -1,4 +1,5 @@ import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' +import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -25,6 +26,7 @@ import { type MobileStructuredTuiSendArgs = { client: RpcClient terminal: string + agent: 'claude' | 'codex' deviceToken: string | null text: string attachments: readonly PendingNativeChatImage[] @@ -36,6 +38,14 @@ const wait = (ms: number): Promise => setTimeout(resolve, ms) }) +export function getMobileStructuredTuiTerminal( + handoff: AgentSessionHandoffStatus | null +): string | null { + return handoff?.owner === 'tui' && handoff.phase === 'idle' + ? (handoff.terminal?.handle ?? null) + : null +} + /** Sends the structured transcript composer through its proven TUI owner. */ export async function sendMobileStructuredTuiMessage( args: MobileStructuredTuiSendArgs @@ -75,7 +85,7 @@ export async function sendMobileStructuredTuiMessage( } const textDeadline = args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline - const classification = classifyMobileNativeChatSend('codex', args.text) + const classification = classifyMobileNativeChatSend(args.agent, args.text) const outcome = classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0 ? await typeMobileNativeChatCommandWithOutcome({ @@ -106,3 +116,38 @@ export async function sendMobileStructuredTuiMessage( releaseMobileNativeChatTerminalWrite(args.terminal) } } + +export async function sendMobileStructuredTuiComposerMessage(args: { + client: RpcClient | null + connected: boolean + agent: 'claude' | 'codex' + handoff: AgentSessionHandoffStatus | null + deviceToken: string | null + text: string + attachments: readonly PendingNativeChatImage[] + onAccepted: () => void + onToast: (message: string, durationMs: number) => void +}): Promise { + const terminal = getMobileStructuredTuiTerminal(args.handoff) + if (!args.client || !args.connected || !terminal) { + args.onToast('Message not sent (disconnected)', 1800) + return false + } + const outcome = await sendMobileStructuredTuiMessage({ + client: args.client, + terminal, + agent: args.agent, + deviceToken: args.deviceToken, + text: args.text, + attachments: args.attachments + }) + if (outcome === 'rejected') { + args.onToast('Message not sent', 1800) + return false + } + args.onAccepted() + if (outcome === 'unknown') { + args.onToast('Delivery unconfirmed — check chat before retrying', 2400) + } + return true +} diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index c3ec2d2c4c5..6a7c4c45347 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -32,7 +32,7 @@ export type MobileStructuredSessionMutations = { requestHandoff: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => Promise } @@ -146,7 +146,7 @@ export function useMobileStructuredSessionMutations(args: { requestHandoff: async ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action: 'start' | 'cancel-queued' | 'retry' = 'start' + action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start' ) => Boolean( await mutate( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index bad13a143b6..128812fa896 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -15,6 +15,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p import type { AgentSessionAccountHome, AgentSessionExecutionLocation, + AgentSessionLaunchEnv, AgentSessionOwnerRuntimeKind, AgentSessionRecord } from '../../../shared/agent-session-record' @@ -61,6 +62,7 @@ export type AgentSessionAttachAuthority = { claimKeyId: string handoffOperationId: string | null probe: AgentSessionOwnerProbe + launchEnv?: AgentSessionLaunchEnv } /** The fields that define WHICH session this call would attach to. Deliberately @@ -177,6 +179,7 @@ export function reserveRequestFor(input: { location: params.location, provider: params.provider, accountHome: params.accountHome, + ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), runtimeKind: params.runtimeKind, expectedFence: params.envelope.expectedRuntimeFence, spawnToken: authority.spawnToken, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts index bfd1b1f0e05..b601b14ead5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -24,6 +24,11 @@ export class StructuredAgentSessionHandoffFlowRunner { await Promise.allSettled(this.active) } + track(task: Promise): void { + this.active.add(task) + void task.finally(() => this.active.delete(task)) + } + begin(input: { callerKey: string params: AgentSessionHandoffRequest @@ -55,8 +60,7 @@ export class StructuredAgentSessionHandoffFlowRunner { this.input.fail(params, error) }) .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) - this.active.add(flow) - void flow.finally(() => this.active.delete(flow)) + this.track(flow) } private run( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts index 00896472a00..2f06d1b9907 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -20,7 +20,7 @@ export class StructuredAgentSessionHandoffOperationGuard { sessionId: string operationId: string fingerprint: string - action: 'start' | 'cancel-queued' | 'retry' + action: 'start' | 'cancel-queued' | 'retry' | 'recover' status?: AgentSessionHandoffStatus now: number }): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts index bee387a9a68..7f289a40e27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -1,3 +1,13 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + export class StructuredAgentSessionHandoffQueue { private readonly controllers = new Map() @@ -42,3 +52,37 @@ export class StructuredAgentSessionHandoffQueue { return false } } + +export function enqueueStructuredHandoffAfterTurn(input: { + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + params: AgentSessionHandoffRequest + tuiOwner: StructuredTuiOwner | undefined + setStatus: (status: AgentSessionHandoffStatus) => void + begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void +}): void { + const { deps, params, queue, tuiOwner } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + input.setStatus({ + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => input.begin({ ...params, mode: 'now' }, tuiReadiness === 'exited') + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts index f159a9a51c4..eb43086b9de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -25,15 +25,21 @@ export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSe return persistedFailedStructuredHandoffStatus(record) } if (record.lease.handoffStage === 'manual-recovery') { + const canRetryProof = + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) return { owner: 'none', - direction: null, + direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native', phase: 'failed', stage: 'manual-recovery', operationId: record.lease.handoffOperationId, error: { message: "Couldn't verify which runtime owns this session — manual recovery is required", - recoverableOwner: 'none' + recoverableOwner: 'none', + ...(canRetryProof ? { canRetryProof: true } : {}) } } } @@ -128,6 +134,12 @@ export function failedStructuredHandoffStatus( : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' ? 'native' : 'none' + const canRetryProof = + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) return { owner: recoverableOwner, direction: params.direction, @@ -143,7 +155,8 @@ export function failedStructuredHandoffStatus( ? "Couldn't open the agent terminal — chat still owns this session" : "Couldn't resume chat — the agent terminal still owns this session", details: error instanceof Error ? error.message : String(error), - recoverableOwner + recoverableOwner, + ...(canRetryProof ? { canRetryProof: true } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index 22edef8848c..fcd01b51cc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -774,27 +774,24 @@ describe('structured session ownership handoff', () => { link: link(fence + 1, 'bad-proof') })) stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved')) - const failedOperation = operationId() - expect(await submit(request('to-tui', 'now', { operationId: failedOperation }))).toMatchObject({ - ok: true - }) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) await waitForPhase('failed') expect(coordinator.status(SESSION)).toMatchObject({ - owner: 'none', stage: 'manual-recovery', - error: { recoverableOwner: 'none' } + error: { recoverableOwner: 'none', canRetryProof: true } + }) + expect(await submit(request('to-tui', 'now', { action: 'recover' }))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null }) expect(acquireNativeCalls).toBe(0) - expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') - await coordinator.restore(SESSION) - expect(coordinator.status(SESSION)).toMatchObject({ - owner: 'none', - stage: 'manual-recovery' - }) - expect( - await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation })) - ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) }) it('retains the stopped TUI session for retry when native resume fails', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 6a15bbdd285..f057ac36124 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -16,7 +16,14 @@ import { } from './structured-agent-session-handoff-admission' import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context' import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' -import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { + beginStructuredManualRecovery, + structuredManualRecoveryIsAdmissible +} from './structured-agent-session-manual-recovery' +import { + enqueueStructuredHandoffAfterTurn, + StructuredAgentSessionHandoffQueue +} from './structured-agent-session-handoff-queue' import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' import { @@ -126,6 +133,28 @@ export class StructuredAgentSessionHandoffCoordinator { 'Agent TUI handoff is unavailable on this host.' ) } + if (action === 'recover') { + if (!structuredManualRecoveryIsAdmissible(record, currentStatus)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This session no longer has a recoverable TUI proof.' + ) + } + const recovery = beginStructuredManualRecovery({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + fingerprint, + requireRecord: (sessionId) => this.requireRecord(sessionId), + restore: (sessionId) => this.restore(sessionId), + setStatus: (sessionId, status) => this.setStatus(sessionId, status) + }) + this.flowRunner.track(recovery) + return this.success(record.sessionId, false) + } if (action === 'retry') { if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { return this.refuseAdmitted( @@ -172,7 +201,14 @@ export class StructuredAgentSessionHandoffCoordinator { ) } if (busy && params.mode === 'after-turn') { - this.queueAfterTurn(callerKey, params, fingerprint) + enqueueStructuredHandoffAfterTurn({ + deps: this.deps, + queue: this.queue, + params, + tuiOwner, + setStatus: (status) => this.setStatus(record.sessionId, status), + begin: (next, exited) => this.begin(callerKey, next, null, fingerprint, exited) + }) return this.success(record.sessionId, false) } if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { @@ -201,45 +237,8 @@ export class StructuredAgentSessionHandoffCoordinator { }) } - private success(sessionId: string, replayed: boolean) { - return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) - } - - private queueAfterTurn( - callerKey: string, - params: AgentSessionHandoffRequest, - fingerprint: string - ): void { - const sessionId = params.envelope.sessionId - let tuiReadiness: 'idle' | 'exited' | null = null - this.setStatus(sessionId, { - owner: params.direction === 'to-tui' ? 'native' : 'tui', - direction: params.direction, - phase: 'queued', - stage: null, - operationId: params.envelope.clientOperationId, - hostLabel: this.deps.transport?.hostLabel - }) - const tuiOwner = this.tuiOwners.get(sessionId) - this.queue.enqueue( - sessionId, - async (signal) => { - if (params.direction === 'to-tui') { - return !activeStructuredAgentSessionTurnId( - this.deps.session(sessionId).journal.snapshot().items - ) - } - tuiReadiness = tuiOwner - ? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) - : null - return tuiReadiness !== null - }, - () => { - const next = { ...params, mode: 'now' as const } - this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited') - } - ) - } + private success = (sessionId: string, replayed: boolean) => + structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) private begin( callerKey: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts new file mode 100644 index 00000000000..245cdec9f17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff' + +function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord { + return { + provider, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: accountHome + } + } as AgentSessionRecord +} + +describe('structured TUI transcript import roots', () => { + it('uses the managed Claude account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual( + { + claudeProjectsDir: join('/managed/claude', 'projects') + } + ) + }) + + it('uses the managed Codex account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({ + codexSessionsDirs: [join('/managed/codex', 'sessions')] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index c5ac3136355..b9c0f21aa09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' import { journalIdentityFor } from './structured-agent-session-attach' import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' @@ -94,14 +95,13 @@ async function importTuiHistory( if (!record || head?.handle.provider !== 'codex') { throw new Error('agent_session_identity_required') } + const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath) const imported = await importLegacyTranscriptIntoJournal({ journal: session.journal, agent: 'codex', sessionId: head.handle.threadId, fence: input.fence, - options: input.transcriptPath - ? { filePath: input.transcriptPath } - : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } + options }) if (!imported.ok) { throw new Error(imported.error) @@ -109,6 +109,18 @@ async function importTuiHistory( host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) } +export function structuredTuiTranscriptImportOptions( + record: AgentSessionRecord, + transcriptPath?: string +): LegacyImportOptions { + if (transcriptPath) { + return { filePath: transcriptPath } + } + return record.provider === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +} + async function acquireNativeHandoffOwner( deps: StructuredAgentSessionHostDeps, host: HostHandoffAccess, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index e0aa61067c7..067f48b5b24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,5 +1,10 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' export type StructuredAgentSessionCaller = { callerKey: string } @@ -8,3 +13,18 @@ export type StructuredAgentSessionHostSession = { params: AgentSessionAttachParams fence: number } + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + mintSpawnToken?: () => string + resolveLaunchEnv?: ( + provider: AgentSessionRecord['provider'] + ) => Record | undefined + now?: () => number + onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 995ee4a2fa5..42da077a9fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -3,11 +3,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionAttachResult, AgentSessionCancelResult, @@ -24,9 +20,7 @@ import type { AgentSessionSendResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { readAgentSessionHistory } from './agent-session-history-page' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach } from './structured-agent-session-attach-flow' import { @@ -47,7 +41,6 @@ import { type AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' -import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' import { createStructuredAgentSessionHostHandoff, refreshRecoverableStructuredHandoffStatus, @@ -58,20 +51,10 @@ import { listStructuredAgentSessionTabs } from './structured-agent-session-host- import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import type { StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -export type StructuredAgentSessionHostDeps = { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - journalRoot: string - claimKeyId: string - probeOwner?: (record: AgentSessionRecord) => Promise - mintSpawnToken?: () => string - now?: () => number - onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - handoffTransport?: StructuredAgentSessionHandoffTransport -} +export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { private readonly sessions = new Map() @@ -169,7 +152,10 @@ export class StructuredAgentSessionHost { spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: this.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, - probe: await this.runtimeState.probeOwner(sessionId) + probe: await this.runtimeState.probeOwner(sessionId), + ...(this.deps.resolveLaunchEnv + ? { launchEnv: this.deps.resolveLaunchEnv(params.provider) } + : {}) }, callerKey: caller.callerKey, params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts new file mode 100644 index 00000000000..e9995fab7ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts @@ -0,0 +1,101 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredManualRecoveryIsAdmissible( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus | undefined +): boolean { + return ( + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + status?.error?.canRetryProof === true + ) +} + +export function beginStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + const { + callerKey, + deps, + fingerprint, + operationGuard, + params, + requireRecord, + restore, + setStatus + } = input + const sessionId = params.envelope.sessionId + operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + setStatus(sessionId, { + owner: 'none', + direction: params.direction, + phase: 'switching', + stage: 'recovering', + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + return deps + .schedule(sessionId, async () => { + let record = requireRecord(sessionId) + if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) { + record = await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: record.lease.handoffOperationId, + now: deps.now() + }) + } + await restore(record.sessionId) + if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') { + throw new Error('The TUI owner proof is still unavailable.') + } + }) + .then(() => + deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + ) + .catch(async (error) => { + await deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + const status = idleStructuredHandoffStatus(requireRecord(sessionId)) + setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: error instanceof Error ? error.message : String(error) + } + } + : {}) + }) + }) + .finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId)) +} diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 7f5c60b2a7a..6b3ccd7ee23 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -18,6 +18,7 @@ import { agentSessionExecutionLocationsEqual, type AgentSessionAccountHome, type AgentSessionExecutionLocation, + type AgentSessionLaunchEnv, type AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' @@ -32,6 +33,7 @@ export type AgentSessionReserveRequest = { location: AgentSessionExecutionLocation provider: AgentSessionHandleProvider accountHome: AgentSessionAccountHome + launchEnv?: AgentSessionLaunchEnv runtimeKind: AgentSessionReservation['runtimeKind'] /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ expectedFence: number | null @@ -142,6 +144,7 @@ function createAgentSessionRecord( provider: request.provider, providerHandleChain: [], accountHome: request.accountHome, + ...(request.launchEnv ? { launchEnv: { ...request.launchEnv } } : {}), createdAt: request.now, updatedAt: request.now, lease: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f934d6ed406..c141655a085 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9361,6 +9361,38 @@ export class OrcaRuntimeService { } } + private recoverClaudeStructuredTuiProof(input: { + candidate: RuntimePtyWorktreeRecord + sessionId: string + spawnToken: string + }): { transcriptPath: string } { + const paneKey = input.candidate.paneKey + if (!paneKey) { + throw new Error('The owning Claude terminal did not retain its pane identity.') + } + const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ + paneKey, + launchTokenHash: createHash('sha256').update(input.spawnToken).digest('hex'), + connectionId: input.candidate.connectionId, + terminalProvenance: 'restored' + }) + if (attestation?.paneKey !== paneKey) { + throw new Error('The owning Claude terminal did not retain launch-token authority.') + } + const matches = (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []).filter( + (row) => + row.paneKey === paneKey && + row.connectionId === input.candidate.connectionId && + row.providerSession?.key === 'session_id' && + row.providerSession.id === input.sessionId && + Boolean(row.providerSession.transcriptPath) + ) + if (matches.length !== 1) { + throw new Error('The owning Claude terminal did not retain a unique resume proof.') + } + return { transcriptPath: matches[0]!.providerSession!.transcriptPath! } + } + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { const pty = this.ptysById.get(owner.terminal.ptyId) if (!pty?.connected) { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 09088144fbd..3621c63a8c7 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -102,6 +102,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + provider === 'claude' ? deps.resolveClaudeLaunchEnv?.() : undefined + } + : {}), probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx index 08368983bab..30bb0103552 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -13,7 +13,7 @@ type Props = { onRequest: ( direction: AgentSessionHandoffDirection, mode: AgentSessionHandoffMode, - action?: 'start' | 'cancel-queued' | 'retry' + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' ) => void } @@ -191,7 +191,16 @@ export function StructuredAgentSessionHandoffChrome({ >
{status.error.message} - {status.direction && status.error.recoverableOwner !== 'none' ? ( + {status.direction && status.error.canRetryProof ? ( + + ) : status.direction && status.error.recoverableOwner !== 'none' ? (