Merge native chat handoff and wire Claude structured sessions

This commit is contained in:
Brennan Benson
2026-08-11 22:01:38 -07:00
106 changed files with 7288 additions and 440 deletions
@@ -229,6 +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 * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send'
import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
import {
isMobileStructuredAgent,
@@ -4671,6 +4672,31 @@ export default function SessionScreen() {
}
return accepted
}}
onTuiSend={async (text, restored) => {
const terminal = mobileStructuredTuiSend.getMobileStructuredTuiTerminal(
structuredSessionEntry.session.handoff
)
if (!client || connState !== 'connected' || !terminal) {
showToast('Message not sent (disconnected)', 1800)
return false
}
const outcome = await mobileStructuredTuiSend.sendMobileStructuredTuiMessage({
client,
terminal,
deviceToken: deviceTokenRef.current,
text,
attachments: [...restored, ...structuredSessionEntry.attachments.attachments]
})
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}
@@ -4680,6 +4706,8 @@ export default function SessionScreen() {
onAttachImage={() => void structuredSessionEntry.attachments.attach('library')}
onRemoveAttachment={structuredSessionEntry.attachments.remove}
onCancel={structuredSessionEntry.writes.cancel}
handoff={structuredSessionEntry.session.handoff}
onRequestHandoff={structuredSessionEntry.writes.requestHandoff}
/>
) : activePendingTerminalTab ? (
<View style={styles.emptyState}>
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, type ComponentProps } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
@@ -50,35 +50,49 @@ function options(): MobileNativeChatSessionOptionsController {
describe('MobileStructuredAgentSessionView command seam', () => {
let renderer: ReactTestRenderer | null = null
const onSend = vi.fn(async () => true)
const onTuiSend = vi.fn(async () => true)
let controller: MobileNativeChatSessionOptionsController
function props(
overrides: Partial<ComponentProps<typeof MobileStructuredAgentSessionView>> = {}
): ComponentProps<typeof MobileStructuredAgentSessionView> {
return {
agent: 'codex',
items: [],
status: 'ready',
hasOlder: false,
loadingOlder: false,
onLoadOlder: async () => false,
outbox: [],
onSend,
onTuiSend,
onTakeQueuedForEdit: async () => null,
onRetry: async () => {},
onRespondToPrompt: async () => true,
sessionOptions: controller,
attachments: [],
isAttaching: false,
onAttachImage: () => {},
onRemoveAttachment: () => {},
onCancel: async () => true,
handoff: null,
onRequestHandoff: async () => true,
...overrides
}
}
function render(overrides = {}): void {
act(() => {
renderer = create(createElement(MobileStructuredAgentSessionView, props(overrides)))
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
onSend.mockClear()
onTuiSend.mockClear()
controller = options()
act(() => {
renderer = create(
createElement(MobileStructuredAgentSessionView, {
agent: 'codex',
items: [],
status: 'ready',
hasOlder: false,
loadingOlder: false,
onLoadOlder: async () => false,
outbox: [],
onSend,
onTakeQueuedForEdit: async () => null,
onRetry: async () => {},
onRespondToPrompt: async () => true,
sessionOptions: controller,
attachments: [],
isAttaching: false,
onAttachImage: () => {},
onRemoveAttachment: () => {},
onCancel: async () => true
})
)
})
render()
})
afterEach(() => {
@@ -112,12 +126,7 @@ describe('MobileStructuredAgentSessionView command seam', () => {
it('uses Claude branding and command suggestions for Claude sessions', () => {
act(() => {
renderer!.update(
createElement(MobileStructuredAgentSessionView, {
...renderer!.root.findByType(MobileStructuredAgentSessionView).props,
agent: 'claude'
})
)
renderer!.update(createElement(MobileStructuredAgentSessionView, props({ agent: 'claude' })))
})
const composer = renderer!.root.findByType('MobileNativeChatComposer')
@@ -134,4 +143,88 @@ describe('MobileStructuredAgentSessionView command seam', () => {
const empty = renderer!.root.findByType('FlatList').props.ListEmptyComponent
expect(empty.props.children[0].props.children).toBe('New Claude chat')
})
it('keeps the composer enabled and routes stable TUI ownership through its bridge', async () => {
act(() => {
renderer!.update(
createElement(
MobileStructuredAgentSessionView,
props({
handoff: {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
}
})
)
)
})
expect(renderer!.root.findByType('MobileNativeChatComposer').props.disabled).toBe(false)
await submit('hello from mobile')
expect(onTuiSend).toHaveBeenCalledWith('hello from mobile', [])
expect(onSend).not.toHaveBeenCalled()
})
it('routes stable TUI slash commands to typed terminal dispatch', async () => {
act(() => {
renderer!.update(
createElement(
MobileStructuredAgentSessionView,
props({
handoff: {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
}
})
)
)
})
await submit('/model')
expect(onTuiSend).toHaveBeenCalledWith('/model', [])
expect(controller.invokeAction).not.toHaveBeenCalled()
expect(onSend).not.toHaveBeenCalled()
})
it('disables and refuses composer sends while ownership is switching', async () => {
act(() => {
renderer!.update(
createElement(
MobileStructuredAgentSessionView,
props({
handoff: {
owner: 'native',
direction: 'to-tui',
phase: 'switching',
stage: 'preparing',
operationId: 'handoff-1'
}
})
)
)
})
const composer = renderer!.root.findByType('MobileNativeChatComposer')
expect(composer.props.disabled).toBe(true)
await act(async () => {
expect(await composer.props.onSend('racing send')).toBe(false)
})
expect(onSend).not.toHaveBeenCalled()
expect(onTuiSend).not.toHaveBeenCalled()
})
it('keeps native ownership on the structured agent-session send path', async () => {
await submit('native message')
expect(onSend).toHaveBeenCalledWith('native message', [])
expect(onTuiSend).not.toHaveBeenCalled()
})
})
@@ -11,6 +11,11 @@ import {
} from 'react-native'
import { Square } from 'lucide-react-native'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type {
AgentSessionHandoffDirection,
AgentSessionHandoffMode,
AgentSessionHandoffStatus
} from '../../../src/shared/agent-session-wire'
import { colors } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
@@ -56,6 +61,7 @@ type Props = {
onOpenFile?: (path: string) => void
outbox: MobileStructuredOutboxEntry[]
onSend: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
onTakeQueuedForEdit: (clientMessageId: string) => Promise<MobileStructuredOutboxEntry | null>
onRetry: (clientMessageId: string) => Promise<void>
onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
@@ -65,6 +71,12 @@ type Props = {
onAttachImage: () => void
onRemoveAttachment: (id: string) => void
onCancel: (turnId: string) => Promise<boolean>
handoff: AgentSessionHandoffStatus | null
onRequestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
) => Promise<boolean>
}
export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
@@ -79,6 +91,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
const allAttachments = [...restored, ...props.attachments]
const agentName = props.agent === 'claude' ? 'Claude' : 'Codex'
const slashCommands = useMemo(() => mobileStructuredSlashCommands(props.agent), [props.agent])
const stableTuiOwner =
props.handoff?.owner === 'tui' &&
props.handoff.phase === 'idle' &&
Boolean(props.handoff.terminal?.handle) &&
Boolean(props.onTuiSend)
const stableNativeOwner =
props.handoff === null ||
props.handoff === undefined ||
(props.handoff.owner === 'native' && props.handoff.phase === 'idle')
const composerEnabled = props.status === 'ready' && (stableNativeOwner || stableTuiOwner)
const rows = useMemo(
() => buildMobileStructuredTimeline(props.items, props.outbox),
[props.items, props.outbox]
@@ -107,6 +129,11 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
return (
<View style={styles.root}>
<MobileStructuredHandoffBanner
status={props.handoff}
isWorking={turnId !== null}
onRequest={props.onRequestHandoff}
/>
<FlatList
ref={listRef}
inverted
@@ -210,10 +237,23 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
value={composerText}
onChangeText={setComposerText}
onSend={async (text) => {
if (!composerEnabled) {
setCommandError('Session ownership is changing. Try again when switching finishes.')
return false
}
if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text, props.agent)) {
setCommandError('Remove attachments before using a chat-session command.')
return false
}
if (stableTuiOwner) {
setCommandError(null)
const accepted = await props.onTuiSend!(text, restored)
if (accepted) {
setComposerText('')
setRestored([])
}
return accepted
}
const command = await dispatchMobileStructuredComposerCommand(
text,
props.sessionOptions,
@@ -247,9 +287,101 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
}
}}
isAttaching={props.isAttaching}
disabled={props.status !== 'ready'}
placeholder={props.status === 'ready' ? `Message ${agentName}` : 'Reconnecting…'}
disabled={!composerEnabled}
placeholder={
props.handoff?.owner === 'tui' && !stableTuiOwner
? 'Agent is open in terminal'
: props.status === 'ready'
? `Message ${agentName}`
: 'Reconnecting…'
}
/>
</View>
)
}
function MobileStructuredHandoffBanner(props: {
status: AgentSessionHandoffStatus | null
isWorking: boolean
onRequest: Props['onRequestHandoff']
}): React.JSX.Element | null {
const status = props.status
if (!status) {
return null
}
if (status.owner === 'native' && status.phase === 'idle') {
return (
<View style={styles.handoffBar}>
{props.isWorking ? (
<>
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'after-turn')}
>
<Text style={styles.handoffButtonText}>Switch after this turn</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'stop-turn')}
>
<Text style={styles.handoffButtonText}>Stop turn and switch</Text>
</Pressable>
</>
) : (
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'now')}
>
<Text style={styles.handoffButtonText}>Open agent TUI</Text>
</Pressable>
)}
</View>
)
}
const copy =
status.phase === 'waiting-for-exit'
? 'Exit the agent terminal to continue in chat.'
: status.phase === 'switching'
? mobileHandoffStageCopy(status)
: (status.error?.message ??
`Agent is open in terminal${status.hostLabel ? ` on ${status.hostLabel}` : ''}`)
return (
<View style={[styles.handoffBanner, status.error ? styles.handoffError : null]}>
<Text style={status.error ? styles.handoffErrorText : styles.handoffText}>{copy}</Text>
{status.phase === 'queued' && status.direction ? (
<Pressable
onPress={() => void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')}
>
<Text style={styles.handoffButtonText}>Cancel</Text>
</Pressable>
) : status.owner === 'tui' && status.phase === 'idle' ? (
<Pressable onPress={() => void props.onRequest('to-native', 'after-turn')}>
<Text style={styles.handoffButtonText}>Return to chat</Text>
</Pressable>
) : status.phase === 'failed' &&
status.direction &&
status.error?.recoverableOwner !== 'none' ? (
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'retry')}>
<Text style={styles.handoffButtonText}>Retry</Text>
</Pressable>
) : null}
</View>
)
}
function mobileHandoffStageCopy(status: AgentSessionHandoffStatus): string {
switch (status.stage) {
case 'preparing':
return status.direction === 'to-tui' ? 'Finishing chat session…' : 'Finishing agent terminal…'
case 'old-owner-stopped':
return status.direction === 'to-tui' ? 'Opening agent terminal…' : 'Resuming chat session…'
case 'new-owner-proving':
return status.direction === 'to-tui' ? 'Verifying agent terminal…' : 'Verifying chat session…'
case 'recovering':
return 'Recovering agent session…'
case 'manual-recovery':
return 'Agent session needs recovery'
default:
return 'Switching session owner…'
}
}
@@ -38,6 +38,42 @@ describe('mobile structured event coalescer', () => {
vi.useRealTimers()
})
it('retains ownership projection while merging a reconnect batch', () => {
vi.useFakeTimers()
const seen: AgentSessionSubscribeEvent[] = []
const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event))
coalescer.push({
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: 1 },
items: [],
removedItemIds: [],
submissions: []
},
fence: 3,
handoff: {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
}
})
coalescer.push(batch(2, 'continued'))
vi.advanceTimersByTime(48)
expect(seen).toEqual([
expect.objectContaining({
type: 'batch',
fence: 3,
handoff: expect.objectContaining({ owner: 'tui', phase: 'idle' })
})
])
vi.useRealTimers()
})
it('flushes pending text before a lifecycle row', () => {
vi.useFakeTimers()
const seen: AgentSessionSubscribeEvent[] = []
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire'
import {
EMPTY_MOBILE_STRUCTURED_AGENT_SESSION,
reduceMobileStructuredAgentSession
@@ -25,6 +26,90 @@ function snapshot(epoch: string, items: AgentJournalRenderItem[]) {
}
describe('mobile structured session reducer', () => {
it('projects every stage across transfer, recovery, and return', () => {
const handoffs: AgentSessionHandoffStatus[] = [
{ owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null },
{
owner: 'native',
direction: 'to-tui',
phase: 'switching',
stage: 'preparing',
operationId: 'forward'
},
{
owner: 'none',
direction: 'to-tui',
phase: 'switching',
stage: 'old-owner-stopped',
operationId: 'forward'
},
{
owner: 'none',
direction: 'to-tui',
phase: 'switching',
stage: 'new-owner-proving',
operationId: 'forward'
},
{
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
},
{
owner: 'none',
direction: null,
phase: 'failed',
stage: 'manual-recovery',
operationId: null,
error: { message: 'Recovery required', recoverableOwner: 'none' }
},
{
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-2', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' }
},
{
owner: 'tui',
direction: 'to-native',
phase: 'switching',
stage: 'preparing',
operationId: 'reverse'
},
{ owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null }
]
let state = EMPTY_MOBILE_STRUCTURED_AGENT_SESSION
const projected: Array<Pick<AgentSessionHandoffStatus, 'owner' | 'phase' | 'stage'>> = []
for (const [index, handoff] of handoffs.entries()) {
state = reduceMobileStructuredAgentSession(state, {
type: 'event',
event: {
...snapshot('epoch-a', []),
fence: index + 1,
snapshot: {
...snapshot('epoch-a', []).snapshot,
cursor: { epoch: 'epoch-a', sequence: index }
},
handoff
}
})
projected.push({
owner: state.handoff!.owner,
phase: state.handoff!.phase,
stage: state.handoff!.stage
})
}
expect(projected).toEqual(handoffs.map(({ owner, phase, stage }) => ({ owner, phase, stage })))
expect(state.handoff).toMatchObject({ owner: 'native', phase: 'idle', stage: null })
})
it('boots from a bounded tail page and retains its live resume cursor', () => {
const state = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, {
type: 'tail-page',
@@ -41,5 +41,30 @@ export const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm
},
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize }
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize },
handoffBar: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs
},
handoffBanner: {
minHeight: 42,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.bgRaised,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
handoffError: { backgroundColor: colors.bgRaised },
handoffText: { flex: 1, color: colors.textSecondary, fontSize: typography.metaSize },
handoffErrorText: { flex: 1, color: colors.statusRed, fontSize: typography.metaSize },
handoffButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs },
handoffButtonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' }
})
@@ -0,0 +1,104 @@
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 {
getMobileStructuredTuiTerminal,
sendMobileStructuredTuiMessage
} from './mobile-structured-tui-send'
function acceptingClient(): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
} as unknown as RpcClient
}
afterEach(() => {
vi.useRealTimers()
resetMobileNativeChatStaleInputForTests()
resetMobileNativeChatTerminalWritesForTests()
})
describe('sendMobileStructuredTuiMessage', () => {
it('accepts only an idle TUI handoff terminal', () => {
expect(
getMobileStructuredTuiTerminal({
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
})
).toBe('term-tui')
expect(
getMobileStructuredTuiTerminal({
owner: 'tui',
direction: 'to-native',
phase: 'waiting-for-exit',
stage: 'wait-for-tui-exit',
operationId: 'handoff-1',
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
})
).toBeNull()
})
it('routes stable TUI composer text through terminal.send', async () => {
const client = acceptingClient()
await expect(
sendMobileStructuredTuiMessage({
client,
terminal: 'term-tui',
deviceToken: 'mobile-1',
text: 'hello',
attachments: []
})
).resolves.toBe('accepted')
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term-tui',
text: '\x15hello',
enter: true,
client: { id: 'mobile-1', type: 'mobile' }
},
expect.objectContaining({ budgetSpansConnect: true })
)
})
it('types stable TUI slash commands one key at a time', async () => {
vi.useFakeTimers()
const client = acceptingClient()
const sending = sendMobileStructuredTuiMessage({
client,
terminal: 'term-tui',
deviceToken: null,
text: '/model',
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', '/', 'm', 'o', 'd', 'e', 'l', '\r'])
expect(
vi
.mocked(client.sendRequest)
.mock.calls.every(
([method, params]) =>
method === 'terminal.send' && (params as { enter: boolean }).enter === false
)
).toBe(true)
})
})
@@ -0,0 +1,117 @@
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 {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
} from './mobile-native-chat-image-send'
import {
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
clearMobileNativeChatInputStale,
healMobileNativeChatStaleInput,
markMobileNativeChatInputStale
} from './mobile-native-chat-stale-input'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
type MobileStructuredTuiSendArgs = {
client: RpcClient
terminal: string
deviceToken: string | null
text: string
attachments: readonly PendingNativeChatImage[]
sleep?: (ms: number) => Promise<void>
}
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
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
): Promise<MobileNativeChatSendOutcome> {
if (!acquireMobileNativeChatTerminalWrite(args.terminal)) {
return 'rejected'
}
try {
const deadline = openMobileNativeChatSendBudget()
const mobileClient = args.deviceToken
? { id: args.deviceToken, type: 'mobile' as const }
: undefined
if (
!(await healMobileNativeChatStaleInput({
client: args.client,
terminal: args.terminal,
deviceToken: args.deviceToken,
deadline
}))
) {
return 'rejected'
}
if (args.attachments.length > 0) {
const pasted = await pasteMobileNativeChatImagePaths({
client: args.client,
terminal: args.terminal,
deviceToken: args.deviceToken,
imagePaths: args.attachments.map((attachment) => attachment.path),
deadline
})
if (!pasted) {
markMobileNativeChatInputStale(args.terminal)
return 'rejected'
}
clearMobileNativeChatInputStale(args.terminal)
await (args.sleep ?? wait)(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
}
const textDeadline =
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
const classification = classifyMobileNativeChatSend('codex', args.text)
const outcome =
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
? await typeMobileNativeChatCommandWithOutcome({
client: args.client,
terminal: args.terminal,
command: args.text,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
: await sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: args.text,
clearInputFirst: args.attachments.length === 0,
...(mobileClient ? { mobileClient } : {}),
deadline: textDeadline
})
if (args.attachments.length > 0 && outcome !== 'accepted') {
markMobileNativeChatInputStale(args.terminal)
}
return outcome
} catch {
if (args.attachments.length > 0) {
markMobileNativeChatInputStale(args.terminal)
}
return 'rejected'
} finally {
releaseMobileNativeChatTerminalWrite(args.terminal)
}
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'r
import { AppState, type AppStateStatus } from 'react-native'
import type {
AgentSessionHistoryResult,
AgentSessionHandoffStatus,
AgentSessionSubscribeEvent
} from '../../../src/shared/agent-session-wire'
import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types'
@@ -57,6 +58,7 @@ export function useMobileStructuredAgentSession(args: {
hasOlder: boolean
loadingOlder: boolean
loadOlder: () => Promise<boolean>
handoff: AgentSessionHandoffStatus | null
} {
const { client, sessionId } = args
const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION)
@@ -134,15 +136,23 @@ export function useMobileStructuredAgentSession(args: {
}
})
}
void client
.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 })
.then((response) => {
void Promise.all([
client.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }),
client.sendRequest('agentSession.handoffStatus', { sessionId }).catch(() => null)
])
.then(([response, handoffResponse]) => {
if (closed) {
return
}
if (!response.ok) {
throw new Error(response.error.message)
}
if (handoffResponse?.ok) {
dispatch({
type: 'handoff',
handoff: handoffResponse.result as AgentSessionHandoffStatus
})
}
const result = response.result as AgentSessionHistoryResult
if (result.ok) {
dispatch({ type: 'tail-page', page: result.page })
@@ -240,6 +250,7 @@ export function useMobileStructuredAgentSession(args: {
error: state.error || undefined,
hasOlder: state.hasOlder,
loadingOlder,
loadOlder
loadOlder,
handoff: state.handoff
}
}
@@ -52,7 +52,8 @@ export function useMobileStructuredSessionEntry(args: {
connected,
sessionId,
fence: session.fence,
submissions: session.submissions
submissions: session.submissions,
handoffOperationId: session.handoff?.operationId
})
const sessionOptions = useMobileStructuredSessionOptions({
client,
@@ -6,6 +6,8 @@ import type {
AgentJournalRenderItem
} from '../../../src/shared/agent-session-journal-types'
import type {
AgentSessionHandoffDirection,
AgentSessionHandoffMode,
AgentSessionMutationResult,
AgentSessionPromptResult
} from '../../../src/shared/agent-session-wire'
@@ -25,12 +27,18 @@ export type MobileStructuredSessionMutations = {
respondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
setOption: (key: string, value: string) => Promise<boolean>
cancel: (turnId: string) => Promise<boolean>
requestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
) => Promise<boolean>
}
export function useMobileStructuredSessionMutations(args: {
client: RpcClient | null
sessionId: string | null
fence: number | null
handoffOperationId?: string | null
onRefusal: (message: string | null) => void
}): MobileStructuredSessionMutations {
const operationIdsRef = useRef(new Map<string, string>())
@@ -44,12 +52,18 @@ export function useMobileStructuredSessionMutations(args: {
activeContextRef.current = { client, fence, sessionId }
}, [client, fence, sessionId])
const mutate = useCallback(
async <TValue>(method: string, fingerprintMethod: string, fields: Record<string, unknown>) => {
async <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>,
operationIdOverride?: string | null
) => {
if (!client || !sessionId || fence === null || client.getState() !== 'connected') {
return null
}
const mutationKey = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}`
const clientOperationId =
operationIdOverride ??
operationIdsRef.current.get(mutationKey) ??
createStructuredAgentSessionOperationId(() => ExpoCrypto.randomUUID())
operationIdsRef.current.set(mutationKey, clientOperationId)
@@ -117,9 +131,22 @@ export function useMobileStructuredSessionMutations(args: {
setOption: async (key: string, value: string) =>
Boolean(await mutate('agentSession.setOption', 'agentSession.setOption', { key, value })),
cancel: async (turnId: string) =>
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId }))
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })),
requestHandoff: async (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action: 'start' | 'cancel-queued' | 'retry' = 'start'
) =>
Boolean(
await mutate(
'agentSession.requestHandoff',
'agentSession.requestHandoff',
{ direction, mode, action },
action === 'retry' ? args.handoffOperationId : null
)
)
}),
[mutate]
[args.handoffOperationId, mutate]
)
}
@@ -31,6 +31,7 @@ export function useMobileStructuredSessionWrites(args: {
sessionId: string | null
fence: number | null
submissions: readonly AgentJournalSubmission[]
handoffOperationId?: string | null
}): MobileStructuredSessionWrites {
const { client, connected, sessionId, fence, submissions } = args
const [outbox, setOutbox] = useState<MobileStructuredOutboxEntry[]>([])
@@ -48,6 +49,7 @@ export function useMobileStructuredSessionWrites(args: {
client,
sessionId,
fence,
handoffOperationId: args.handoffOperationId,
onRefusal: setError
})
+35
View File
@@ -97,6 +97,41 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('AgentHookServer raw Claude proof envelopes', () => {
it('publishes the authenticated SessionStart body before status normalization', async () => {
const server = new AgentHookServer()
const received: unknown[] = []
const unsubscribe = server.subscribeClaudeHookEnvelopes((envelope) => received.push(envelope))
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const body = buildBody(
{
hook_event_name: 'SessionStart',
source: 'resume',
session_id: 'claude-session',
transcript_path: '/tmp/claude-session.jsonl'
},
{ launchToken: 'claude-spawn-token' }
)
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(body)
})
expect(response.status).toBe(204)
expect(received).toEqual([body])
} finally {
unsubscribe()
server.stop()
}
})
})
describe('AgentHookServer listener replay', () => {
it('preserves Codex sibling and lead state across relay listener restarts', () => {
const server = new AgentHookServer()
+21
View File
@@ -604,6 +604,7 @@ export class AgentHookServer {
private paneStatusClearListeners = new Set<PaneStatusClearListener>()
private statusChangeListeners = new Set<StatusChangeListener>()
private providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
private claudeHookEnvelopeListeners = new Set<(envelope: unknown) => void>()
// Why: setListener is a single slot owned by the main-window fanout; the
// plugin event bus (and future consumers) need an additive subscription
// that also works in headless serve, where no window listener exists.
@@ -674,6 +675,23 @@ export class AgentHookServer {
}
}
subscribeClaudeHookEnvelopes(listener: (envelope: unknown) => void): () => void {
this.claudeHookEnvelopeListeners.add(listener)
return () => {
this.claudeHookEnvelopeListeners.delete(listener)
}
}
private emitClaudeHookEnvelope(envelope: unknown): void {
for (const listener of this.claudeHookEnvelopeListeners) {
try {
listener(envelope)
} catch (err) {
console.error('[agent-hooks] Claude hook envelope listener threw', err)
}
}
}
/** Multi-subscriber tap on every enriched status change (no replay). */
subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void {
this.enrichedStatusListeners.add(listener)
@@ -2145,6 +2163,9 @@ export class AgentHookServer {
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
if (source === 'claude') {
this.emitClaudeHookEnvelope(aliasedBody)
}
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest'
import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
assertLegacyAiVaultResumeAllowed,
@@ -0,0 +1,160 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as pty from 'node-pty'
import { afterEach, describe, expect, it } from 'vitest'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { openCodexThread } from './codex-structured-thread-open'
import { proveCodexTuiRollout } from './codex-tui-rollout-proof'
const codexCommand = resolveCodexCommand()
const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0
const itWithCodex = codexAvailable ? it : it.skip
const tempHomes: string[] = []
async function waitForTuiStart(proc: pty.IPty): Promise<string> {
let output = ''
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)),
15_000
)
proc.onData((data) => {
output += data
if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) {
clearTimeout(timeout)
resolve(output)
}
})
proc.onExit(({ exitCode }) => {
clearTimeout(timeout)
reject(
new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`)
)
})
})
}
async function waitForRollout(path: string, threadId: string): Promise<string> {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
try {
const rollout = await readFile(path, 'utf8')
if (rollout.includes(threadId)) {
return rollout
}
} catch {
// The rollout is created asynchronously after thread/start.
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('Codex did not materialize the resumed rollout')
}
afterEach(async () => {
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
})
describe('real Codex structured-to-TUI resume', () => {
itWithCodex(
'resumes the exact isolated rollout and reaches the initial TUI screen',
async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-'))
tempHomes.push(codexHome)
await writeFile(
join(codexHome, 'config.toml'),
[
'model_provider = "orca-integration"',
'model = "gpt-5"',
'',
'[model_providers.orca-integration]',
'name = "Orca integration"',
'base_url = "http://127.0.0.1:9/v1"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
`[projects.${JSON.stringify(process.cwd())}]`,
'trust_level = "trusted"',
''
].join('\n')
)
const connection = await openCodexAppServerConnection({
command: codexCommand,
args: ['app-server'],
env: { CODEX_HOME: codexHome }
})
const opened = await openCodexThread(
connection,
{ cwd: process.cwd(), resumeThreadId: null },
15_000
)
await connection.request(
'turn/start',
{
threadId: opened.threadId,
clientUserMessageId: 'real-binary-resume-fixture',
input: [{ type: 'text', text: 'materialize the isolated resume fixture' }]
},
{ timeoutMs: 15_000 }
)
expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId)
await connection.close()
expect(opened.historyPath).toContain(opened.threadId)
expect(opened.historyPath).toContain(join(codexHome, 'sessions'))
const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
CODEX_HOME: codexHome,
ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof',
TERM: 'xterm-256color'
}
})
const tuiExit = new Promise<number>((resolve) =>
tui.onExit(({ exitCode }) => resolve(exitCode))
)
const kittyKeyboard = new TerminalKittyKeyboardModeTracker()
let tuiOutput = ''
let lastOutputAt: number | null = null
tui.onData((data) => {
tuiOutput += data
lastOutputAt = Date.now()
kittyKeyboard.scan(data)
})
try {
await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i)
const proof = await proveCodexTuiRollout({
codexHome,
threadId: opened.threadId,
kittyKeyboardFlags: kittyKeyboard.flags,
readOutput: () => ({ text: tuiOutput, lastOutputAt }),
write: (data) => {
tui.write(data)
return true
}
})
expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!))
} finally {
try {
tui.kill()
} catch {
// Already exited.
}
await Promise.race([
tuiExit,
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000)
)
])
}
},
30_000
)
})
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest'
import {
codexTuiStatusProbeInput,
parseCodexTuiStatusSessionId,
proveCodexTuiRollout,
resolvePinnedCodexRolloutProof
} from './codex-tui-rollout-proof'
const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70'
const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71'
describe('Codex TUI rollout proof', () => {
it('parses the exact session shown by status', () => {
expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD)
expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD)
expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD)
expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull()
})
it('uses Kitty Enter only while the TUI negotiated Kitty input', () => {
expect(codexTuiStatusProbeInput(0)).toEqual({
command: '\u001b[200~/status\u001b[201~',
submit: '\r'
})
expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u')
expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u')
})
it('resolves only the exact session_meta rollout under the pinned account home', async () => {
const files = async function* (): AsyncGenerator<string> {
yield '/pinned/sessions/scratch/rollout-wrong.jsonl'
yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
}
const readSessionMetaId = vi.fn(async () => THREAD)
await expect(
resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId })
).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`)
expect(readSessionMetaId).toHaveBeenCalledTimes(1)
})
it('rejects a rollout whose session_meta names another thread', async () => {
const files = async function* (): AsyncGenerator<string> {
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
}
await expect(
resolvePinnedCodexRolloutProof('/pinned', THREAD, {
listFiles: files,
readSessionMetaId: async () => OTHER_THREAD
})
).resolves.toBeNull()
})
it('rejects a different status session after filesystem proof', async () => {
let reads = 0
const write = vi.fn((_data: string) => true)
await expect(
proveCodexTuiRollout({
codexHome: '/pinned',
threadId: THREAD,
kittyKeyboardFlags: 0,
readOutput: () => ({
text: `Session: ${OTHER_THREAD}`,
lastOutputAt: reads++ > 0 ? 2 : 1
}),
write,
resolveRollout: async () => '/pinned/sessions/rollout.jsonl',
delay: async () => undefined
})
).rejects.toThrow('resumed a different Codex session')
expect(write).toHaveBeenCalledTimes(2)
})
it('dismisses status only after the exact session is observed', async () => {
let reads = 0
const write = vi.fn((_data: string) => true)
await expect(
proveCodexTuiRollout({
codexHome: '/pinned',
threadId: THREAD,
kittyKeyboardFlags: 1,
readOutput: () => ({
text: reads++ > 0 ? `Session: ${THREAD}` : '',
lastOutputAt: reads > 1 ? 2 : 1
}),
write,
resolveRollout: async () => '/pinned/sessions/rollout.jsonl',
delay: async () => undefined
})
).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' })
expect(write.mock.calls.map(([data]) => data)).toEqual([
'\u001b[200~/status\u001b[201~',
'\u001b[13u',
'\u001b'
])
})
})
+144
View File
@@ -0,0 +1,144 @@
import { open } from 'node:fs/promises'
import { join } from 'node:path'
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
import { relativePathInsideRoot } from '../../shared/cross-platform-path'
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi')
const ROLLOUT_READ_LIMIT = 64 * 1024
const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~'
const KITTY_ENTER = '\u001b[13u'
export type CodexTuiProofOutput = {
text: string
lastOutputAt: number | null
}
export type CodexTuiRolloutProofOptions = {
listFiles?: (sessionsRoot: string) => AsyncIterable<string>
readSessionMetaId?: (filePath: string) => Promise<string | null>
}
export function parseCodexTuiStatusSessionId(output: string): string | null {
let sessionId: string | null = null
for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) {
sessionId = match[1] ?? null
}
return sessionId
}
export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string {
return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r'
}
export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): {
command: string
submit: string
} {
return {
command: STATUS_COMMAND_PASTE,
submit: codexTuiStatusSubmitInput(kittyKeyboardFlags)
}
}
export async function resolvePinnedCodexRolloutProof(
codexHome: string,
threadId: string,
options: CodexTuiRolloutProofOptions = {}
): Promise<string | null> {
const sessionsRoot = join(codexHome, 'sessions')
const listFiles =
options.listFiles ??
((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 }))
const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId
const expectedSuffix = `-${threadId}.jsonl`.toLowerCase()
for await (const filePath of listFiles(sessionsRoot)) {
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/')
if (
!relativePath ||
!/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) ||
!relativePath.toLowerCase().endsWith(expectedSuffix)
) {
continue
}
if ((await readSessionMetaId(filePath)) === threadId) {
return filePath
}
}
return null
}
export async function proveCodexTuiRollout(input: {
codexHome: string
threadId: string
kittyKeyboardFlags: number
readOutput: () => CodexTuiProofOutput
write: (data: string) => boolean
timeoutMs?: number
resolveRollout?: (codexHome: string, threadId: string) => Promise<string | null>
delay?: (ms: number) => Promise<void>
}): Promise<{ transcriptPath: string }> {
const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof
const transcriptPath = await resolveRollout(input.codexHome, input.threadId)
if (!transcriptPath) {
throw new Error('The agent terminal did not prove the expected Codex rollout.')
}
const baselineOutputAt = input.readOutput().lastOutputAt
const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags)
if (!input.write(probe.command)) {
throw new Error('The agent terminal could not verify its Codex session.')
}
const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
await delay(100)
if (!input.write(probe.submit)) {
throw new Error('The agent terminal could not verify its Codex session.')
}
const deadline = Date.now() + (input.timeoutMs ?? 15_000)
while (Date.now() < deadline) {
const output = input.readOutput()
if (output.lastOutputAt !== baselineOutputAt) {
const observedThreadId = parseCodexTuiStatusSessionId(output.text)
if (observedThreadId && observedThreadId !== input.threadId) {
throw new Error('The agent terminal resumed a different Codex session.')
}
if (observedThreadId === input.threadId) {
if (!input.write('\u001b')) {
throw new Error('The agent terminal could not finish Codex session verification.')
}
return { transcriptPath }
}
}
await delay(100)
}
throw new Error('The agent terminal did not prove the expected Codex rollout.')
}
async function readCodexRolloutSessionMetaId(filePath: string): Promise<string | null> {
const file = await open(filePath, 'r')
try {
const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT)
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0)
const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim()
if (!firstLine) {
return null
}
const record = JSON.parse(firstLine) as {
type?: unknown
id?: unknown
payload?: { id?: unknown }
}
if (record.type !== 'session_meta') {
return null
}
const id = record.payload?.id ?? record.id
return typeof id === 'string' && id.length > 0 ? id : null
} catch {
return null
} finally {
await file.close()
}
}
+12
View File
@@ -5338,6 +5338,7 @@ export function registerPtyHandlers(
}
const response = {
id: result.id,
...(result.pid ? { pid: result.pid } : {}),
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle)
? {
@@ -5389,6 +5390,17 @@ export function registerPtyHandlers(
return false
}
},
writeAgentSessionProof: (ptyId, data, authority) => {
if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) {
return false
}
try {
getProviderForPty(ptyId).write(ptyId, data)
return true
} catch {
return false
}
},
probePtyLiveness: async (ptyId) => {
try {
// Why: no locally routed provider can authoritatively answer for a
+34
View File
@@ -85,6 +85,40 @@ describe('registerRuntimeHandlers', () => {
})
})
it('projects Claude structured tabs to the same-version desktop client', async () => {
const claudeTab = {
type: 'agent-session',
id: 'agent-session:claude-1',
title: 'Claude Chat',
sessionId: 'claude-1',
agent: 'claude',
isActive: true
}
const runtime = {
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
listMobileSessionTabs: vi.fn(async () => ({
worktree: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: claudeTab.id,
activeTabType: 'agent-session',
tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }],
tabs: [claudeTab]
}))
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
const result = await callRegistration![1](
{ sender: {} },
{ method: 'session.tabs.list', params: { worktree: 'id:workspace-1' } }
)
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
})
it('registers project group runtime RPC methods for local desktop callers', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
+11 -3
View File
@@ -9,10 +9,18 @@ import type {
} from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import {
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import { ALL_RPC_METHODS } from '../runtime/rpc/methods'
const LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES = [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
] as const
function boundTerminalFitRestore(pending: Promise<boolean>): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<boolean>((resolve) => {
@@ -62,7 +70,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
{
clientId: 'desktop-renderer',
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES
}
)) as RuntimeRpcResponse<unknown>
}
@@ -104,7 +112,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
signal: controller.signal,
clientId: 'desktop-renderer',
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES
}
)
.finally(stop)
@@ -62,6 +62,15 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
historyFilePath = (input: { identity: AgentSessionJournalIdentity }) =>
this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null)
async closeSession(sessionId: string): Promise<void> {
const adapter = this.owners.get(sessionId)
if (!adapter) {
return
}
await adapter.closeSession?.(sessionId)
this.owners.delete(sessionId)
}
async closeAll(): Promise<void> {
this.owners.clear()
await this.closeAdapters()
@@ -105,4 +105,6 @@ export type StructuredAgentSessionAdapter = {
/** Transcript path for journal recovery. Omit to let the existing session-file
* resolver discover it from the provider session id. */
historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise<string | null>
/** Gracefully stops the structured owner after its event stream is drained. */
closeSession?(sessionId: string): Promise<void>
}
@@ -0,0 +1,126 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire'
import {
agentSessionFingerprintConflict,
computeAgentSessionPayloadFingerprint
} from '../../../shared/agent-session-mutation-envelope'
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
export type StructuredHandoffAdmission =
| { decision: 'continue'; record: AgentSessionRecord; fingerprint: string }
| { decision: 'replay'; outcome: AgentSessionOperationOutcome }
| { decision: 'refused'; refusal: AgentSessionWireRefusal }
export async function admitStructuredHandoffRequest(input: {
deps: StructuredAgentSessionHandoffDeps
operationGuard: StructuredAgentSessionHandoffOperationGuard
callerKey: string
params: AgentSessionHandoffRequest
record: AgentSessionRecord
status?: AgentSessionHandoffStatus
}): Promise<StructuredHandoffAdmission> {
const action = input.params.action ?? 'start'
const requestFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.requestHandoff',
sessionId: input.record.sessionId,
fields: { direction: input.params.direction, mode: input.params.mode, action }
})
const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint)
if (conflict) {
return { decision: 'refused', refusal: conflict }
}
const fingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.requestHandoff.operation',
sessionId: input.record.sessionId,
fields: { direction: input.params.direction }
})
const operation = await input.operationGuard.check({
callerKey: input.callerKey,
sessionId: input.record.sessionId,
operationId: input.params.envelope.clientOperationId,
fingerprint,
action,
...(input.status ? { status: input.status } : {}),
now: input.deps.now()
})
if (operation.decision === 'replay') {
return { decision: 'replay', outcome: operation.outcome }
}
if (operation.decision === 'refused') {
return {
decision: 'refused',
refusal: {
code: operation.code as 'agent_session_operation_conflict',
message: 'This handoff operation could not be admitted.'
}
}
}
if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) {
await input.deps.store.recordOperationOutcome({
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' }
})
return {
decision: 'refused',
refusal: {
code: 'agent_session_checkpoint_stale',
message: 'The session owner changed before the handoff request arrived.',
currentFence: input.record.lease.runtimeFence
}
}
}
return { decision: 'continue', record: input.record, fingerprint }
}
export function replayedStructuredHandoffRefusal(
outcome: AgentSessionOperationOutcome
): AgentSessionWireRefusal | null {
if (
outcome.status !== 'failed' ||
!AGENT_SESSION_WIRE_REFUSAL_CODES.includes(
outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number]
)
) {
return null
}
return {
code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number],
message: 'This handoff request was previously refused.'
}
}
export async function refuseAdmittedStructuredHandoff(input: {
deps: StructuredAgentSessionHandoffDeps
callerKey: string
params: AgentSessionHandoffRequest
refusal: AgentSessionWireRefusal
}): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
await input.deps.store.recordOperationOutcome({
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: { status: 'failed', code: input.refusal.code }
})
return { ok: false, refusal: input.refusal }
}
export function structuredHandoffRetryIsAdmissible(
status: AgentSessionHandoffStatus,
params: AgentSessionHandoffRequest
): boolean {
return (
status.phase === 'failed' &&
status.direction === params.direction &&
status.operationId === params.envelope.clientOperationId &&
status.error?.recoverableOwner !== 'none'
)
}
@@ -0,0 +1,33 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type {
StructuredAgentSessionHandoffDeps,
StructuredAgentSessionHandoffFlowContext,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
export function createStructuredHandoffFlowContext(input: {
deps: StructuredAgentSessionHandoffDeps
owner: (sessionId: string) => StructuredTuiOwner | undefined
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
releaseOwner: (sessionId: string) => void
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
enterPreparing: StructuredAgentSessionHandoffFlowContext['enterPreparing']
publishStage: StructuredAgentSessionHandoffFlowContext['publishStage']
requireRecord: (sessionId: string) => AgentSessionRecord
}): StructuredAgentSessionHandoffFlowContext {
return input
}
export async function stopStructuredNativeTurn(
deps: StructuredAgentSessionHandoffDeps,
sessionId: string,
turnId: string
): Promise<boolean> {
const record = deps.store.getRecord(sessionId)
if (!record || record.lease.runtimeKind !== 'native') {
return false
}
const session = deps.session(sessionId)
return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false
}
@@ -0,0 +1,85 @@
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context'
import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward'
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse'
import type {
StructuredAgentSessionHandoffDeps,
StructuredAgentSessionHandoffFlowContext
} from './structured-agent-session-handoff-types'
export class StructuredAgentSessionHandoffFlowRunner {
private readonly active = new Set<Promise<void>>()
constructor(
private readonly input: {
deps: StructuredAgentSessionHandoffDeps
operationGuard: StructuredAgentSessionHandoffOperationGuard
flowContext: () => StructuredAgentSessionHandoffFlowContext
fail: (params: AgentSessionHandoffRequest, error: unknown) => void
}
) {}
async drain(): Promise<void> {
await Promise.allSettled(this.active)
}
begin(input: {
callerKey: string
params: AgentSessionHandoffRequest
turnId: string | null
fingerprint: string
tuiAlreadyExited?: boolean
}): void {
const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input
const sessionId = params.envelope.sessionId
this.input.operationGuard.start(sessionId, {
callerKey,
operationId: params.envelope.clientOperationId,
fingerprint
})
const flow = this.run(params, turnId, tuiAlreadyExited)
.then(() =>
this.input.deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'succeeded', sessionId }
})
)
.catch(async (error) => {
await this.input.deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
})
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))
}
private run(
params: AgentSessionHandoffRequest,
turnId: string | null,
tuiAlreadyExited: boolean
): Promise<void> {
const sessionId = params.envelope.sessionId
return this.input.deps.schedule(sessionId, async () => {
if (turnId && params.mode === 'stop-turn') {
const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId)
if (!stopped) {
throw new Error('The current turn did not acknowledge cancellation.')
}
}
await (params.direction === 'to-tui'
? handoffStructuredSessionToTui(this.input.flowContext(), params, params.action === 'retry')
: handoffStructuredSessionToNative(
this.input.flowContext(),
params,
params.action === 'retry',
tuiAlreadyExited
))
})
}
}
@@ -0,0 +1,165 @@
import { randomUUID } from 'node:crypto'
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
import {
abandonStoredAgentSessionHandoffAttempt,
reserveStoredAgentSessionHandoffOwner,
setStoredAgentSessionHandoffStage,
stopStoredAgentSessionOwnerForHandoff
} from '../../runtime/agent-session-handoff-record-transitions'
import type {
StructuredAgentSessionHandoffFlowContext,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
export async function handoffStructuredSessionToTui(
context: StructuredAgentSessionHandoffFlowContext,
params: AgentSessionHandoffRequest,
retry: boolean
): Promise<void> {
const { deps } = context
const sessionId = params.envelope.sessionId
const operationId = params.envelope.clientOperationId
let record = context.requireRecord(sessionId)
if (retry && record.lease.handoffStage === 'old-owner-stopped') {
record = await recoverNativeAfterTuiFailure(context, sessionId, operationId)
}
if (record.lease.handoffStage === null) {
await context.enterPreparing(record, operationId, 'to-tui')
} else if (
record.lease.handoffStage !== 'preparing' ||
record.lease.handoffOperationId !== operationId
) {
throw new Error('agent_session_operation_conflict')
}
record = context.requireRecord(sessionId)
await deps.suspendNative(sessionId)
record = await stopStoredAgentSessionOwnerForHandoff(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
operationId,
now: deps.now()
})
context.publishStage(record, 'to-tui')
const spawnToken = randomUUID()
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
runtimeKind: 'tui',
spawnToken,
operationId,
claimKeyId: deps.claimKeyId,
now: deps.now()
})
context.publishStage(record, 'to-tui')
let owner: StructuredTuiOwner | null = null
try {
owner = await deps.transport!.launchTui({
record,
fence: record.lease.runtimeFence,
spawnToken
})
await deps.store.commitProcessIdentity({
sessionId,
fence: record.lease.runtimeFence,
process: owner.process,
now: deps.now()
})
record = await deps.store.proveOwner({
sessionId,
fence: record.lease.runtimeFence,
link: owner.link,
now: deps.now()
})
} catch (error) {
if (owner) {
if (!deps.transport?.stopFailedTuiLaunch) {
await markManualRecovery(context, sessionId, operationId)
throw error
}
try {
await deps.transport.stopFailedTuiLaunch(owner)
} catch (stopError) {
await markManualRecovery(context, sessionId, operationId)
throw new AggregateError(
[error, stopError],
'The failed terminal launch could not be proven stopped.'
)
}
}
await recoverNativeAfterTuiFailure(context, sessionId, operationId)
throw error
}
context.retainOwner(sessionId, owner)
context.setStatus(sessionId, {
owner: 'tui',
direction: null,
phase: 'idle',
stage: record.lease.handoffStage,
operationId: record.lease.handoffOperationId,
terminal: owner.terminal,
hostLabel: deps.transport?.hostLabel
})
}
async function markManualRecovery(
context: StructuredAgentSessionHandoffFlowContext,
sessionId: string,
operationId: string
): Promise<void> {
const { deps } = context
const record = context.requireRecord(sessionId)
await setStoredAgentSessionHandoffStage(deps.store, {
sessionId,
fence: record.lease.runtimeFence,
stage: 'manual-recovery',
handoffOperationId: operationId,
now: deps.now()
})
}
async function recoverNativeAfterTuiFailure(
context: StructuredAgentSessionHandoffFlowContext,
sessionId: string,
operationId: string
) {
const { deps } = context
let record = context.requireRecord(sessionId)
if (record.lease.handoffStage === 'new-owner-proving') {
record = await abandonStoredAgentSessionHandoffAttempt(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
operationId,
recoverableRuntimeKind: 'native',
now: deps.now()
})
}
const spawnToken = randomUUID()
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
runtimeKind: 'native',
spawnToken,
operationId,
claimKeyId: deps.claimKeyId,
now: deps.now()
})
try {
return await deps.acquireNative({
sessionId,
fence: record.lease.runtimeFence,
spawnToken
})
} catch (error) {
const current = context.requireRecord(sessionId)
if (current.lease.handoffStage === 'new-owner-proving') {
await abandonStoredAgentSessionHandoffAttempt(deps.store, {
sessionId,
expectedFence: current.lease.runtimeFence,
operationId,
recoverableRuntimeKind: 'native',
now: deps.now()
})
}
throw error
}
}
@@ -0,0 +1,73 @@
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string }
export type HandoffOperationDecision =
| { decision: 'new' }
| { decision: 'replay'; outcome: AgentSessionOperationOutcome }
| { decision: 'retry' }
| { decision: 'refused'; code: 'agent_session_operation_conflict' | string }
export class StructuredAgentSessionHandoffOperationGuard {
private readonly activeBySession = new Map<string, ActiveOperation>()
constructor(private readonly store: AgentSessionRecordStore) {}
async check(input: {
callerKey: string
sessionId: string
operationId: string
fingerprint: string
action: 'start' | 'cancel-queued' | 'retry'
status?: AgentSessionHandoffStatus
now: number
}): Promise<HandoffOperationDecision> {
const active = this.activeBySession.get(input.sessionId)
if (
active?.operationId === input.operationId &&
(active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey)
) {
return { decision: 'refused', code: 'agent_session_operation_conflict' }
}
const ledger = await this.store.admitOperation({
callerKey: input.callerKey,
operationId: input.operationId,
fingerprint: input.fingerprint,
now: input.now
})
if (ledger.decision === 'refused') {
return { decision: 'refused', code: ledger.code }
}
if (ledger.decision === 'admit') {
return { decision: 'new' }
}
if (input.action === 'retry' && ledger.row.outcome.status === 'failed') {
await this.store.recordOperationOutcome({
callerKey: input.callerKey,
operationId: input.operationId,
outcome: { status: 'pending' }
})
return { decision: 'retry' }
}
if (
ledger.row.outcome.status === 'pending' &&
!active &&
input.status?.operationId !== input.operationId
) {
return { decision: 'new' }
}
return { decision: 'replay', outcome: ledger.row.outcome }
}
start(sessionId: string, operation: ActiveOperation): void {
this.activeBySession.set(sessionId, operation)
}
finish(sessionId: string, operationId: string): void {
if (this.activeBySession.get(sessionId)?.operationId === operationId) {
this.activeBySession.delete(sessionId)
}
}
}
@@ -0,0 +1,44 @@
export class StructuredAgentSessionHandoffQueue {
private readonly controllers = new Map<string, AbortController>()
cancel(sessionId: string): void {
this.controllers.get(sessionId)?.abort()
this.controllers.delete(sessionId)
}
enqueue(
sessionId: string,
isIdle: (signal: AbortSignal) => boolean | Promise<boolean>,
onReady: () => void
): void {
this.cancel(sessionId)
const controller = new AbortController()
this.controllers.set(sessionId, controller)
void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => {
if (ready) {
onReady()
}
})
}
private async waitUntilIdle(
sessionId: string,
controller: AbortController,
isIdle: (signal: AbortSignal) => boolean | Promise<boolean>
): Promise<boolean> {
while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) {
try {
if (await isIdle(controller.signal)) {
this.controllers.delete(sessionId)
return true
}
} catch {
if (controller.signal.aborted) {
return false
}
}
await new Promise((resolve) => setTimeout(resolve, 150))
}
return false
}
}
@@ -0,0 +1,281 @@
import { randomUUID } from 'node:crypto'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffStatus
} from '../../../shared/agent-session-wire'
import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation'
import {
abandonStoredAgentSessionHandoffAttempt,
setStoredAgentSessionHandoffStage,
stopStoredAgentSessionOwnerForHandoff,
stopStoredRecoveringTuiOwnerForHandoff
} from '../../runtime/agent-session-handoff-record-transitions'
import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward'
import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse'
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
import type {
StructuredAgentSessionHandoffDeps,
StructuredAgentSessionHandoffFlowContext,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
type RestartAccess = {
deps: StructuredAgentSessionHandoffDeps
requireRecord: (sessionId: string) => AgentSessionRecord
flowContext: () => StructuredAgentSessionHandoffFlowContext
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
}
export async function restoreStructuredAgentSessionHandoff(
input: RestartAccess,
sessionId: string
): Promise<void> {
const initial = input.requireRecord(sessionId)
const operationId = initial.lease.handoffOperationId
const initialStage = initial.lease.handoffStage
if (
(initialStage === 'recovering' || initialStage === 'manual-recovery') &&
!canRestoreLiveTuiOwner(initial)
) {
if (operationId) {
await input.deps.store.recordOperationOutcome({
operationId,
outcome: { status: 'failed', code: 'agent_session_ownership_unknown' }
})
}
input.setStatus(sessionId, idleStructuredHandoffStatus(initial))
return
}
let lastError: unknown
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await restoreOnce(input, input.requireRecord(sessionId))
const settled = input.requireRecord(sessionId)
if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) {
throw new Error('Restart handoff reconciliation did not settle the transfer.')
}
if (operationId) {
await input.deps.store.recordOperationOutcome({
operationId,
outcome: { status: 'succeeded', sessionId }
})
}
return
} catch (error) {
lastError = error
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt))
}
}
}
const current = input.requireRecord(sessionId)
const failed = await setStoredAgentSessionHandoffStage(input.deps.store, {
sessionId,
fence: current.lease.runtimeFence,
stage: 'manual-recovery',
handoffOperationId: current.lease.handoffOperationId,
now: input.deps.now()
})
const status = idleStructuredHandoffStatus(failed)
if (operationId) {
await input.deps.store.recordOperationOutcome({
operationId,
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
})
}
input.setStatus(sessionId, {
...status,
...(status.error
? {
error: {
...status.error,
details: lastError instanceof Error ? lastError.message : String(lastError)
}
}
: {})
})
}
async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
if (canRestoreLiveTuiOwner(record)) {
await restoreRecoverableLiveTui(input, record)
return
}
if (!input.deps.transport) {
if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') {
throw new Error('Agent TUI handoff recovery is unavailable on this host.')
}
return
}
if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') {
await restoreLiveTui(input, record)
return
}
if (!record.lease.handoffOperationId) {
return
}
if (record.lease.handoffStage === 'preparing') {
await restorePreparing(input, record)
return
}
if (record.lease.handoffStage === 'new-owner-proving') {
await restoreProving(input, record)
return
}
if (record.lease.handoffStage === 'old-owner-stopped') {
await continueHandoff(input, record)
}
}
async function recoverUnavailableTuiAsNative(
input: RestartAccess,
record: AgentSessionRecord
): Promise<void> {
await input.deps.transport!.stopRecoveredOwner(record)
const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now())
const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, {
sessionId: record.sessionId,
expectedFence: record.lease.runtimeFence,
operationId,
now: input.deps.now()
})
await continueHandoff(input, stopped)
}
export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean {
return (
(record.lease.handoffStage === 'recovering' ||
record.lease.handoffStage === 'manual-recovery') &&
record.lease.runtimeKind === 'tui' &&
record.lease.claimStatus === 'live' &&
record.lease.ownerProcess !== null &&
record.lease.handoffOperationId === null
)
}
async function restoreRecoverableLiveTui(
input: RestartAccess,
record: AgentSessionRecord
): Promise<void> {
if (!input.deps.transport) {
throw new Error('Agent TUI handoff recovery is unavailable on this host.')
}
let owner: StructuredTuiOwner
try {
const recovered = await input.deps.transport.recoverTuiOwner(record)
owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered })
} catch {
await recoverUnavailableTuiAsNative(input, record)
return
}
await setStoredAgentSessionHandoffStage(input.deps.store, {
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
stage: null,
handoffOperationId: null,
now: input.deps.now()
})
input.retainOwner(record.sessionId, owner)
input.setStatus(record.sessionId, {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: owner.terminal,
hostLabel: input.deps.transport.hostLabel
})
}
async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
const owner = await input.deps.transport!.recoverTuiOwner(record)
input.retainOwner(record.sessionId, owner)
input.setStatus(record.sessionId, {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: owner.terminal,
hostLabel: input.deps.transport?.hostLabel
})
}
async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
if (record.lease.runtimeKind === 'tui') {
const owner = await input.deps.transport!.recoverTuiOwner(record)
await input.deps.transport!.reproveTuiOwner({ record, owner })
const settled = await setStoredAgentSessionHandoffStage(input.deps.store, {
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
stage: null,
handoffOperationId: null,
now: input.deps.now()
})
await restoreLiveTui(input, settled)
return
}
await input.deps.transport!.stopRecoveredOwner(record)
const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, {
sessionId: record.sessionId,
expectedFence: record.lease.runtimeFence,
operationId: record.lease.handoffOperationId!,
now: input.deps.now()
})
await continueHandoff(input, stopped)
}
async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
const operationId = record.lease.handoffOperationId!
if (record.lease.runtimeKind === 'tui') {
const owner = await input.deps.transport!.recoverTuiOwner(record)
const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner })
await input.deps.store.proveOwner({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
link: reproved.link,
now: input.deps.now()
})
input.retainOwner(record.sessionId, reproved)
input.setStatus(record.sessionId, {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: reproved.terminal,
hostLabel: input.deps.transport?.hostLabel
})
return
}
await input.deps.transport!.stopRecoveredOwner(record)
const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, {
sessionId: record.sessionId,
expectedFence: record.lease.runtimeFence,
operationId,
recoverableRuntimeKind: 'tui',
now: input.deps.now()
})
await continueHandoff(input, stopped)
}
async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native'
const operationId = record.lease.handoffOperationId!
const params: AgentSessionHandoffRequest = {
envelope: {
sessionId: record.sessionId,
clientOperationId: operationId,
expectedRuntimeFence: record.lease.runtimeFence,
payloadFingerprint: 'restart-reconciliation'
},
direction,
mode: 'now',
action: 'retry'
}
await (direction === 'to-tui'
? handoffStructuredSessionToTui(input.flowContext(), params, true)
: handoffStructuredSessionToNative(input.flowContext(), params, true))
}
@@ -0,0 +1,32 @@
import type {
AgentSessionHandoffResult,
AgentSessionMutationResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
export function structuredHandoffRefusal(
code: AgentSessionWireRefusal['code'],
message: string
): AgentSessionWireRefusal {
return { code, message }
}
export function structuredHandoffSuccess(
deps: StructuredAgentSessionHandoffDeps,
sessionId: string,
replayed: boolean,
status: AgentSessionHandoffResult['status']
): AgentSessionMutationResult<AgentSessionHandoffResult> {
const record = deps.store.getRecord(sessionId)
if (!record) {
throw new Error('agent_session_identity_required')
}
return {
ok: true,
replayed,
fence: record.lease.runtimeFence,
cursor: deps.session(sessionId).journal.cursor(),
value: { status }
}
}
@@ -0,0 +1,128 @@
import { randomUUID } from 'node:crypto'
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
import {
abandonStoredAgentSessionHandoffAttempt,
reserveStoredAgentSessionHandoffOwner,
rollbackStoredAgentSessionHandoffPreparation,
stopStoredAgentSessionOwnerForHandoff
} from '../../runtime/agent-session-handoff-record-transitions'
import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types'
export async function handoffStructuredSessionToNative(
context: StructuredAgentSessionHandoffFlowContext,
params: AgentSessionHandoffRequest,
retry: boolean,
tuiAlreadyExited = false
): Promise<void> {
const { deps } = context
const sessionId = params.envelope.sessionId
const operationId = params.envelope.clientOperationId
let record = context.requireRecord(sessionId)
let owner = context.owner(sessionId)
let transcriptPath = owner?.transcriptPath
if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) {
if (record.lease.handoffStage === null) {
await context.enterPreparing(record, operationId, 'to-native')
}
record = context.requireRecord(sessionId)
try {
if (!owner) {
throw new Error('The owning agent terminal could not be identified.')
}
if (!tuiAlreadyExited) {
owner = await deps.transport!.reproveTuiOwner({ record, owner })
context.retainOwner(sessionId, owner)
}
transcriptPath = owner.transcriptPath ?? transcriptPath
context.setStatus(sessionId, {
owner: 'tui',
direction: 'to-native',
phase: 'waiting-for-exit',
stage: 'preparing',
operationId,
terminal: owner.terminal,
hostLabel: deps.transport?.hostLabel
})
const exited = await deps.transport!.waitForTuiExit(owner, async (link) => {
await deps.store.recordProviderHandle({
sessionId,
fence: record.lease.runtimeFence,
link,
now: deps.now()
})
})
transcriptPath = exited.transcriptPath ?? owner.transcriptPath
} catch (error) {
const current = context.requireRecord(sessionId)
if (
current.lease.handoffStage === 'preparing' &&
current.lease.handoffOperationId === operationId &&
current.lease.claimStatus === 'live' &&
current.lease.ownerProcess
) {
await rollbackStoredAgentSessionHandoffPreparation(deps.store, {
sessionId,
expectedFence: current.lease.runtimeFence,
operationId,
now: deps.now()
})
}
throw error
}
record = await stopStoredAgentSessionOwnerForHandoff(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
operationId,
now: deps.now()
})
context.publishStage(record, 'to-native')
} else if (
record.lease.handoffStage !== 'old-owner-stopped' ||
record.lease.handoffOperationId !== operationId
) {
throw new Error('agent_session_operation_conflict')
}
await deps.importTuiHistory({
sessionId,
fence: record.lease.runtimeFence,
...(transcriptPath ? { transcriptPath } : {})
})
const spawnToken = randomUUID()
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
sessionId,
expectedFence: record.lease.runtimeFence,
runtimeKind: 'native',
spawnToken,
operationId,
claimKeyId: deps.claimKeyId,
now: deps.now()
})
context.publishStage(record, 'to-native')
try {
record = await deps.acquireNative({
sessionId,
fence: record.lease.runtimeFence,
spawnToken
})
} catch (error) {
const current = context.requireRecord(sessionId)
if (current.lease.handoffStage === 'new-owner-proving') {
await abandonStoredAgentSessionHandoffAttempt(deps.store, {
sessionId,
expectedFence: current.lease.runtimeFence,
operationId,
recoverableRuntimeKind: 'tui',
now: deps.now()
})
}
throw error
}
context.releaseOwner(sessionId)
context.setStatus(sessionId, {
owner: 'native',
direction: null,
phase: 'idle',
stage: record.lease.handoffStage,
operationId: record.lease.handoffOperationId
})
}
@@ -0,0 +1,149 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffStatus
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
StructuredAgentSessionHandoffTransport,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
export function structuredTuiStatus(
owner: StructuredTuiOwner | undefined,
transport: StructuredAgentSessionHandoffTransport | undefined
): 'idle' | 'busy' {
return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy'
}
export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus {
if (
record.lease.handoffStage === 'old-owner-stopped' &&
record.lease.claimStatus === 'released' &&
record.lease.handoffOperationId
) {
return persistedFailedStructuredHandoffStatus(record)
}
if (record.lease.handoffStage === 'manual-recovery') {
return {
owner: 'none',
direction: null,
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'
}
}
}
if (record.lease.handoffStage) {
const direction =
record.lease.handoffStage === 'preparing'
? record.lease.runtimeKind === 'native'
? 'to-tui'
: 'to-native'
: record.lease.runtimeKind === 'tui'
? 'to-tui'
: 'to-native'
return {
owner:
record.lease.claimStatus === 'live' && record.lease.ownerProcess
? record.lease.runtimeKind
: 'none',
direction,
phase: 'switching',
stage: record.lease.handoffStage,
operationId: record.lease.handoffOperationId
}
}
return {
owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none',
direction: null,
phase: 'idle',
stage: record.lease.handoffStage,
operationId: record.lease.handoffOperationId
}
}
function persistedFailedStructuredHandoffStatus(
record: AgentSessionRecord
): AgentSessionHandoffStatus {
const recoverableOwner = record.lease.runtimeKind
const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native'
return {
owner: recoverableOwner,
direction,
phase: 'failed',
stage: 'old-owner-stopped',
operationId: record.lease.handoffOperationId,
error: {
message:
direction === 'to-tui'
? "Couldn't open the agent terminal — chat still owns this session"
: "Couldn't resume chat — the agent terminal still owns this session",
recoverableOwner
}
}
}
export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean {
return journal
.snapshot()
.items.some(
(item) =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending'
)
}
export function switchingStructuredHandoffStatus(
record: AgentSessionRecord,
direction: 'to-tui' | 'to-native',
hostLabel?: string
): AgentSessionHandoffStatus {
return {
owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none',
direction,
phase: 'switching',
stage: record.lease.handoffStage,
operationId: record.lease.handoffOperationId,
...(hostLabel ? { hostLabel } : {})
}
}
export function failedStructuredHandoffStatus(
record: AgentSessionRecord,
params: AgentSessionHandoffRequest,
error: unknown,
hostLabel?: string
): AgentSessionHandoffStatus {
const recoverableOwner =
record.lease.handoffStage === 'manual-recovery'
? 'none'
: record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released'
? record.lease.runtimeKind
: record.lease.ownerProcess
? record.lease.runtimeKind
: params.direction === 'to-tui' && record.lease.runtimeKind === 'native'
? 'native'
: 'none'
return {
owner: recoverableOwner,
direction: params.direction,
phase: 'failed',
stage: record.lease.handoffStage,
operationId: params.envelope.clientOperationId,
...(hostLabel ? { hostLabel } : {}),
error: {
message:
recoverableOwner === 'none'
? "Couldn't verify which runtime owns this session — manual recovery is required"
: params.direction === 'to-tui'
? "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
}
}
}
@@ -0,0 +1,78 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import type {
StructuredAgentSessionHandoffTransport,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
type TestCoordinatorInput = {
store: AgentSessionRecordStore
journal: AgentSessionJournal
sessionId: string
provider: 'claude' | 'codex'
claudeSessionId: string
codexThreadId: string
now: number
launchTui: StructuredAgentSessionHandoffTransport['launchTui']
reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner']
stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner']
waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit']
waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit']
stopFailedTuiLaunch: NonNullable<StructuredAgentSessionHandoffTransport['stopFailedTuiLaunch']>
recoverTuiOwner: (record: AgentSessionRecord) => Promise<StructuredTuiOwner>
tuiStatus: () => 'idle' | 'busy'
acquireNative: (input: {
sessionId: string
fence: number
spawnToken: string
}) => Promise<AgentSessionRecord>
acquireNativeStop: (turnId: string) => Promise<boolean>
takeImportFailure: () => Error | null
statuses: AgentSessionHandoffStatus[]
}
export function createStructuredAgentSessionHandoffTestCoordinator(
input: TestCoordinatorInput
): StructuredAgentSessionHandoffCoordinator {
return new StructuredAgentSessionHandoffCoordinator({
store: input.store,
claimKeyId: 'key-1',
transport: {
hostLabel: 'Test host',
launchTui: input.launchTui,
reproveTuiOwner: input.reproveTuiOwner,
recoverTuiOwner: input.recoverTuiOwner,
stopRecoveredOwner: input.stopRecoveredOwner,
waitForTuiExit: input.waitForTuiExit,
waitForTuiIdleOrExit: input.waitForTuiIdleOrExit,
tuiStatus: input.tuiStatus,
stopFailedTuiLaunch: input.stopFailedTuiLaunch
},
session: () => ({
journal: input.journal,
fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1
}),
suspendNative: async () => undefined,
acquireNative: input.acquireNative,
acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId),
importTuiHistory: async ({ fence }) => {
const importFailure = input.takeImportFailure()
if (importFailure) {
throw importFailure
}
await input.journal.appendItem(
input.provider === 'claude'
? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' }
: { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 },
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] },
{ fence, recovered: true }
)
},
publish: (_sessionId, status) => input.statuses.push(status),
schedule: async (_sessionId, task) => task(),
now: () => input.now
})
}
@@ -0,0 +1,40 @@
export type StructuredHandoffProviderCase = {
provider: 'claude' | 'codex'
accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string }
}
export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [
{ provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } },
{
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' }
}
]
export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) {
return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken }
}
export function structuredHandoffTestLink(input: {
provider: 'claude' | 'codex'
fence: number
id: string
now: number
claudeSessionId: string
codexThreadId: string
}) {
return {
linkId: input.id,
handle:
input.provider === 'claude'
? ({
provider: 'claude' as const,
sessionId: input.claudeSessionId,
leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf'
} as const)
: ({ provider: 'codex' as const, threadId: input.codexThreadId } as const),
origin: 'resumed' as const,
mintedAtFence: input.fence,
observedAt: input.now
}
}
@@ -0,0 +1,52 @@
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type {
AgentSessionHandoffDirection,
AgentSessionHandoffMode,
AgentSessionHandoffRequest
} from '../../../shared/agent-session-wire'
export type StructuredHandoffTestRequestOptions = {
action?: 'start' | 'cancel-queued' | 'retry'
operationId?: string
}
export class StructuredHandoffTestRequests {
private operations = 0
constructor(
private readonly now: number,
private readonly sessionId: string,
private readonly readFence: () => number
) {}
reset(): void {
this.operations = 0
}
operationId(): string {
this.operations += 1
return `${this.now}-${this.operations.toString(16).padStart(32, '0')}`
}
request(
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
options: StructuredHandoffTestRequestOptions = {}
): AgentSessionHandoffRequest {
const action = options.action ?? 'start'
const fields = { direction, mode, action }
return {
envelope: {
sessionId: this.sessionId,
clientOperationId: options.operationId ?? this.operationId(),
expectedRuntimeFence: this.readFence(),
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.requestHandoff',
sessionId: this.sessionId,
fields
})
},
...fields
}
}
}
@@ -0,0 +1,77 @@
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionProcessIdentity,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
export type StructuredTuiOwner = {
terminal: { handle: string; tabId: string; paneKey: string; ptyId: string }
process: AgentSessionProcessIdentity
link: AgentSessionProviderHandleLink
transcriptPath?: string
}
export type StructuredAgentSessionHandoffTransport = {
hostLabel: string
launchTui(input: {
record: AgentSessionRecord
fence: number
spawnToken: string
}): Promise<StructuredTuiOwner>
reproveTuiOwner(input: {
record: AgentSessionRecord
owner: StructuredTuiOwner
}): Promise<StructuredTuiOwner>
recoverTuiOwner(record: AgentSessionRecord): Promise<StructuredTuiOwner>
stopRecoveredOwner(record: AgentSessionRecord): Promise<void>
waitForTuiExit(
owner: StructuredTuiOwner,
persistHandle: (link: AgentSessionProviderHandleLink) => Promise<void>
): Promise<{ transcriptPath?: string }>
waitForTuiIdleOrExit(
owner: StructuredTuiOwner,
signal: AbortSignal
): Promise<'idle' | 'exited' | null>
tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy'
stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise<void>
}
export type StructuredAgentSessionHandoffDeps = {
store: AgentSessionRecordStore
claimKeyId: string
transport?: StructuredAgentSessionHandoffTransport
session: (sessionId: string) => { journal: AgentSessionJournal; fence: number }
suspendNative: (sessionId: string) => Promise<void>
acquireNative: (input: {
sessionId: string
fence: number
spawnToken: string
}) => Promise<AgentSessionRecord>
acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise<boolean>
importTuiHistory: (input: {
sessionId: string
fence: number
transcriptPath?: string
}) => Promise<void>
publish: (sessionId: string, status: AgentSessionHandoffStatus) => void
schedule: (sessionId: string, task: () => Promise<void>) => Promise<void>
now: () => number
}
export type StructuredAgentSessionHandoffFlowContext = {
deps: StructuredAgentSessionHandoffDeps
owner: (sessionId: string) => StructuredTuiOwner | undefined
retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void
releaseOwner: (sessionId: string) => void
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
enterPreparing: (
record: AgentSessionRecord,
operationId: string,
direction: 'to-tui' | 'to-native'
) => Promise<void>
publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void
requireRecord: (sessionId: string) => AgentSessionRecord
}
@@ -0,0 +1,874 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffStatus
} from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import {
reserveStoredAgentSessionHandoffOwner,
setStoredAgentSessionHandoffStage,
stopStoredAgentSessionOwnerForHandoff
} from '../../runtime/agent-session-handoff-record-transitions'
import { openAgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import { createStructuredAgentSessionHandoffTestCoordinator } from './structured-agent-session-handoff-test-coordinator'
import {
STRUCTURED_HANDOFF_PROVIDER_CASES,
structuredHandoffTestLink,
structuredHandoffTestProcess,
type StructuredHandoffProviderCase
} from './structured-agent-session-handoff-test-identities'
import {
StructuredHandoffTestRequests,
type StructuredHandoffTestRequestOptions
} from './structured-agent-session-handoff-test-requests'
import type {
StructuredAgentSessionHandoffTransport,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
const NOW = 1_800_000_000_000
const SESSION = 'session-handoff'
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61'
let providerCase: StructuredHandoffProviderCase
let root: string
let store: AgentSessionRecordStore
let journal: Awaited<ReturnType<typeof openAgentSessionJournal>>
let coordinator: StructuredAgentSessionHandoffCoordinator
let statuses: AgentSessionHandoffStatus[]
let tuiIdle: boolean
let tuiReadiness: 'idle' | 'exited' | null
let importFailure: Error | null
let nativeAcquireFailure: Error | null
let launchTui: ReturnType<typeof vi.fn<StructuredAgentSessionHandoffTransport['launchTui']>>
let waitForTuiExit: ReturnType<
typeof vi.fn<StructuredAgentSessionHandoffTransport['waitForTuiExit']>
>
let waitForTuiIdleOrExit: ReturnType<
typeof vi.fn<StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit']>
>
let reproveTuiOwner: ReturnType<
typeof vi.fn<StructuredAgentSessionHandoffTransport['reproveTuiOwner']>
>
let stopFailedTuiLaunch: ReturnType<
typeof vi.fn<NonNullable<StructuredAgentSessionHandoffTransport['stopFailedTuiLaunch']>>
>
let acquireNativeStop: ReturnType<typeof vi.fn<(turnId: string) => Promise<boolean>>>
let acquireNativeCalls: number
let stopRecoveredOwner: ReturnType<
typeof vi.fn<StructuredAgentSessionHandoffTransport['stopRecoveredOwner']>
>
const requests = new StructuredHandoffTestRequests(
NOW,
SESSION,
() => store.getRecord(SESSION)?.lease.runtimeFence ?? 1
)
const operationId = (): string => requests.operationId()
function request(
direction: Parameters<StructuredHandoffTestRequests['request']>[0],
mode: Parameters<StructuredHandoffTestRequests['request']>[1],
options: StructuredHandoffTestRequestOptions = {}
): AgentSessionHandoffRequest {
return requests.request(direction, mode, options)
}
function submit(params: AgentSessionHandoffRequest) {
return coordinator.request('client-1', params)
}
function process(spawnToken: string, pid: number) {
return structuredHandoffTestProcess(NOW, spawnToken, pid)
}
function link(fence: number, id: string) {
return structuredHandoffTestLink({
provider: providerCase.provider,
fence,
id,
now: NOW,
claudeSessionId: CLAUDE_SESSION,
codexThreadId: THREAD
})
}
async function establishNativeOwner(): Promise<void> {
const reserved = await store.reserveOwner({
sessionId: SESSION,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: providerCase.provider,
accountHome: {
variable: providerCase.accountHome.variable,
path: join(root, providerCase.accountHome.pathName)
},
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'native-initial',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' },
now: NOW
})
const fence = reserved.record.lease.runtimeFence
await store.commitProcessIdentity({
sessionId: SESSION,
fence,
process: process('native-initial', 4100),
now: NOW
})
await store.proveOwner({
sessionId: SESSION,
fence,
link: { ...link(fence, 'initial-link'), origin: 'created' },
now: NOW
})
}
function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner {
return {
terminal: {
handle: 'term-tui',
tabId: 'tab-tui',
paneKey: 'tab-tui:leaf-tui',
ptyId: 'pty-tui'
},
process: process(spawnToken, 4200),
link: link(fence, `tui-link-${fence}`),
transcriptPath: join(root, 'rollout.jsonl')
}
}
async function acquireNative(input: {
sessionId: string
fence: number
spawnToken: string
}): Promise<AgentSessionRecord> {
acquireNativeCalls += 1
if (providerCase.provider === 'claude') {
const chain = store.getRecord(input.sessionId)?.providerHandleChain ?? []
if (
chain.some(
(entry) => entry.handle.provider === 'claude' && entry.handle.leafUuid === 'tui-exit-leaf'
)
) {
expect(chain.at(-1)?.handle).toEqual({
provider: 'claude',
sessionId: CLAUDE_SESSION,
leafUuid: 'tui-exit-leaf'
})
}
}
if (nativeAcquireFailure) {
const error = nativeAcquireFailure
nativeAcquireFailure = null
throw error
}
await store.commitProcessIdentity({
sessionId: input.sessionId,
fence: input.fence,
process: process(input.spawnToken, 4300 + acquireNativeCalls),
now: NOW
})
return store.proveOwner({
sessionId: input.sessionId,
fence: input.fence,
link: link(input.fence, `native-link-${input.fence}`),
now: NOW
})
}
function createCoordinator(): StructuredAgentSessionHandoffCoordinator {
return createStructuredAgentSessionHandoffTestCoordinator({
store,
journal,
sessionId: SESSION,
provider: providerCase.provider,
claudeSessionId: CLAUDE_SESSION,
codexThreadId: THREAD,
now: NOW,
launchTui,
reproveTuiOwner,
recoverTuiOwner: async (record) => {
const owner = makeTuiOwner(
record.lease.runtimeFence,
record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered'
)
return { ...owner, process: record.lease.ownerProcess ?? owner.process }
},
stopRecoveredOwner,
waitForTuiExit,
waitForTuiIdleOrExit,
tuiStatus: () => (tuiIdle ? 'idle' : 'busy'),
stopFailedTuiLaunch,
acquireNative,
acquireNativeStop: (turnId) => acquireNativeStop(turnId),
takeImportFailure: () => {
if (importFailure) {
const error = importFailure
importFailure = null
return error
}
return null
},
statuses
})
}
async function appendStatus(state: 'running' | 'completed'): Promise<void> {
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'turn-status' },
{ kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } },
{ fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }
)
}
async function waitForPhase(phase: AgentSessionHandoffStatus['phase']): Promise<void> {
await vi.waitFor(() => expect(coordinator.status(SESSION).phase).toBe(phase))
}
async function moveToTui(): Promise<void> {
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui' }))
}
async function setup(): Promise<void> {
root = await mkdtemp(join(tmpdir(), `orca-${providerCase.provider}-handoff-`))
requests.reset()
statuses = []
tuiIdle = true
tuiReadiness = 'idle'
importFailure = null
nativeAcquireFailure = null
acquireNativeCalls = 0
stopRecoveredOwner = vi.fn(async () => undefined)
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
await establishNativeOwner()
journal = await openAgentSessionJournal({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: providerCase.provider,
providerHandle:
providerCase.provider === 'claude'
? { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'current-leaf' }
: { kind: 'codex', threadId: THREAD }
},
journalDir: join(root, 'journal')
})
launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken))
waitForTuiExit = vi.fn(async (owner, persistHandle) => {
if (owner.link.handle.provider === 'claude') {
await persistHandle({
...owner.link,
linkId: `tui-exit-${owner.link.mintedAtFence}`,
handle: { ...owner.link.handle, leafUuid: 'tui-exit-leaf' }
})
}
return { transcriptPath: owner.transcriptPath }
})
waitForTuiIdleOrExit = vi.fn(async () => tuiReadiness)
reproveTuiOwner = vi.fn(async ({ record, owner }) => {
expect(record.lease.ownerProcess).toEqual(owner.process)
if (record.lease.provenHandleLinkId !== null) {
expect(record.lease.provenHandleLinkId).toBe(owner.link.linkId)
}
return owner
})
stopFailedTuiLaunch = vi.fn(async () => undefined)
acquireNativeStop = vi.fn(async () => true)
coordinator = createCoordinator()
}
async function teardown(): Promise<void> {
await coordinator.drain()
await rm(root, { recursive: true, force: true })
}
describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)(
'$provider structured session ownership handoff',
(currentProvider) => {
beforeEach(async () => {
providerCase = currentProvider
await setup()
})
afterEach(teardown)
it('completes native to TUI to native with one owner and journal continuity', async () => {
await journal.appendItem(
providerCase.provider === 'claude'
? { provider: 'claude', sessionId: CLAUDE_SESSION, uuid: 'native-turn' }
: { provider: 'codex', threadId: THREAD, turnId: 'native-turn', ordinal: 0 },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from native' }] },
{ fence: 1 }
)
await moveToTui()
const forwardRecord = store.getRecord(SESSION)!
expect(forwardRecord.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null
})
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' }))
expect(reproveTuiOwner).toHaveBeenCalledWith({
record: expect.objectContaining({
lease: expect.objectContaining({
ownerProcess: forwardRecord.lease.ownerProcess,
provenHandleLinkId: forwardRecord.lease.provenHandleLinkId
})
}),
owner: expect.objectContaining({
process: forwardRecord.lease.ownerProcess,
link: expect.objectContaining({ linkId: forwardRecord.lease.provenHandleLinkId })
})
})
const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message')
expect(messages).toHaveLength(2)
expect(new Set(messages.map((item) => item.itemId)).size).toBe(2)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null
})
})
it('queues a busy native turn, supports cancel, and starts only after idle', async () => {
await appendStatus('running')
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
expect(coordinator.status(SESSION).phase).toBe('queued')
expect(
await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
).toMatchObject({ ok: true })
await appendStatus('completed')
await new Promise((resolve) => setTimeout(resolve, 200))
expect(launchTui).not.toHaveBeenCalled()
await appendStatus('running')
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
await appendStatus('completed')
await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce())
})
it('durably replays a completed queued cancellation after restart', async () => {
await appendStatus('running')
expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true })
const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' })
expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: false })
await coordinator.drain()
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
coordinator = createCoordinator()
expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: true })
expect(launchTui).not.toHaveBeenCalled()
})
it('replays a duplicate active handoff without launching a second owner', async () => {
let finishLaunch!: () => void
launchTui.mockImplementationOnce(
({ fence, spawnToken }) =>
new Promise((resolve) => {
finishLaunch = () => resolve(makeTuiOwner(fence, spawnToken))
})
)
const operation = operationId()
const first = request('to-tui', 'now', { operationId: operation })
expect(await submit(first)).toMatchObject({ ok: true, replayed: false })
await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce())
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'reserved',
handoffStage: 'new-owner-proving'
})
expect(coordinator.status(SESSION).owner).not.toBe('tui')
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
finishLaunch()
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
expect(launchTui).toHaveBeenCalledOnce()
})
it('replays the same completed handoff operation after the coordinator restarts', async () => {
const operation = operationId()
const first = request('to-tui', 'now', { operationId: operation })
expect(await submit(first)).toMatchObject({ ok: true, replayed: false })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
await coordinator.drain()
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
coordinator = createCoordinator()
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
expect(launchTui).toHaveBeenCalledOnce()
})
it('continues a persisted preparing stage after restart instead of stranding it', async () => {
const operation = operationId()
const first = request('to-tui', 'now', { operationId: operation })
await store.admitOperation({
callerKey: 'client-1',
operationId: operation,
fingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.requestHandoff.operation',
sessionId: SESSION,
fields: { direction: 'to-tui' }
}),
now: NOW
})
await setStoredAgentSessionHandoffStage(store, {
sessionId: SESSION,
fence: 1,
stage: 'preparing',
handoffOperationId: operation,
now: NOW
})
coordinator = createCoordinator()
await coordinator.restore(SESSION)
expect(stopRecoveredOwner).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null
})
await coordinator.drain()
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
coordinator = createCoordinator()
expect(await submit(first)).toMatchObject({ ok: true, replayed: true })
expect(launchTui).toHaveBeenCalledOnce()
})
it('finishes a live new-owner-proving stage after restart', async () => {
const operation = operationId()
let record = await setStoredAgentSessionHandoffStage(store, {
sessionId: SESSION,
fence: 1,
stage: 'preparing',
handoffOperationId: operation,
now: NOW
})
record = await stopStoredAgentSessionOwnerForHandoff(store, {
sessionId: SESSION,
expectedFence: record.lease.runtimeFence,
operationId: operation,
now: NOW
})
const spawnToken = 'restarted-tui'
record = await reserveStoredAgentSessionHandoffOwner(store, {
sessionId: SESSION,
expectedFence: record.lease.runtimeFence,
runtimeKind: 'tui',
spawnToken,
operationId: operation,
claimKeyId: 'key-1',
now: NOW
})
await store.commitProcessIdentity({
sessionId: SESSION,
fence: record.lease.runtimeFence,
process: process(spawnToken, 4400),
now: NOW
})
coordinator = createCoordinator()
await coordinator.restore(SESSION)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null
})
expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' })
})
it('clears stale recovery after re-proving the live TUI owner', async () => {
await moveToTui()
const record = store.getRecord(SESSION)!
await setStoredAgentSessionHandoffStage(store, {
sessionId: SESSION,
fence: record.lease.runtimeFence,
stage: 'manual-recovery',
handoffOperationId: null,
now: NOW
})
coordinator = createCoordinator()
await coordinator.restore(SESSION)
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
phase: 'idle',
stage: null,
terminal: { handle: 'term-tui' }
})
expect(reproveTuiOwner).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
})
it('recovers native ownership when a stressed TUI recovery process is gone', async () => {
await moveToTui()
const record = store.getRecord(SESSION)!
await setStoredAgentSessionHandoffStage(store, {
sessionId: SESSION,
fence: record.lease.runtimeFence,
stage: 'manual-recovery',
handoffOperationId: null,
now: NOW
})
reproveTuiOwner.mockRejectedValueOnce(new Error('The owning terminal was killed.'))
coordinator = createCoordinator()
await coordinator.restore(SESSION)
expect(stopRecoveredOwner).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: SESSION,
lease: expect.objectContaining({ runtimeKind: 'tui' })
})
)
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'native',
phase: 'idle',
stage: null
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
})
it('rejects cancellation once a queued handoff is no longer pending', async () => {
expect(
await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' }))
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } })
expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' })
})
it('durably replays a queued-cancellation refusal as a refusal', async () => {
const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' })
expect(await submit(cancellation)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_conflict' }
})
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
coordinator = createCoordinator()
expect(await submit(cancellation)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_conflict' }
})
expect(launchTui).not.toHaveBeenCalled()
})
it('requires acknowledged cancellation before stopping a native turn', async () => {
await appendStatus('running')
expect(await submit(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
expect(acquireNativeStop).toHaveBeenCalledWith('turn-1')
expect(acquireNativeStop.mock.invocationCallOrder[0]).toBeLessThan(
launchTui.mock.invocationCallOrder[0] ?? Infinity
)
})
it('fires a queued TUI transfer from the turn-completion waiter', async () => {
await moveToTui()
tuiIdle = false
tuiReadiness = null
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
expect(coordinator.status(SESSION).phase).toBe('queued')
expect(waitForTuiExit).not.toHaveBeenCalled()
tuiReadiness = 'idle'
await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce())
expect(tuiIdle).toBe(false)
expect(waitForTuiIdleOrExit).toHaveBeenCalled()
})
it('completes a mobile-originated queued reverse after the owning TUI exits', async () => {
await moveToTui()
tuiIdle = false
tuiReadiness = null
const reverse = request('to-native', 'after-turn')
expect(await coordinator.request('mobile-device-a', reverse)).toMatchObject({ ok: true })
expect(coordinator.status(SESSION).phase).toBe('queued')
tuiReadiness = 'exited'
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(reproveTuiOwner).not.toHaveBeenCalled()
expect(waitForTuiExit).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null
})
})
it('re-proves the current TUI handle before accepting its exit and importing history', async () => {
await moveToTui()
const reverse = request('to-native', 'now')
expect(await submit(reverse)).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(reproveTuiOwner).toHaveBeenCalledOnce()
expect(reproveTuiOwner.mock.invocationCallOrder[0]).toBeLessThan(
waitForTuiExit.mock.invocationCallOrder[0] ?? Infinity
)
})
it('does not close or import when the current TUI handle cannot be re-proved', async () => {
await moveToTui()
reproveTuiOwner.mockRejectedValueOnce(new Error('provider handle changed'))
expect(await submit(request('to-native', 'now'))).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(waitForTuiExit).not.toHaveBeenCalled()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null
})
})
it('rolls a stale reverse handle back to one durable TUI owner and retries', async () => {
await moveToTui()
waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale'))
const retryOperation = operationId()
expect(
await submit(request('to-native', 'now', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
operationId: retryOperation,
error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledTimes(2)
})
it('keeps one recoverable owner when a queued reverse auto-fire fails', async () => {
await moveToTui()
tuiIdle = false
tuiReadiness = null
waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale'))
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
expect(coordinator.status(SESSION).phase).toBe('queued')
tuiReadiness = 'idle'
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
})
it('recovers native ownership when TUI launch or proof fails', async () => {
launchTui.mockRejectedValueOnce(new Error('resume proof failed'))
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'native',
error: { recoverableOwner: 'native' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null
})
})
it('stops a launched but uncommitted TUI before recovering native', async () => {
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
...makeTuiOwner(fence, spawnToken),
link: link(fence + 1, 'bad-proof')
}))
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(stopFailedTuiLaunch).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native')
})
it('keeps a recoverable native owner after repeated transfers and a dead TUI relaunch', async () => {
for (let cycle = 0; cycle < 2; cycle += 1) {
await moveToTui()
expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
}
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
...makeTuiOwner(fence, spawnToken),
link: link(fence + 1, 'dead-relaunch')
}))
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(stopFailedTuiLaunch).toHaveBeenCalledOnce()
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'native',
error: { recoverableOwner: 'native' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
})
it('requires manual recovery when a failed TUI launch cannot be proven stopped', async () => {
launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({
...makeTuiOwner(fence, spawnToken),
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
})
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'none',
stage: 'manual-recovery',
error: { recoverableOwner: 'none' }
})
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 () => {
await moveToTui()
importFailure = new Error('history unavailable')
const retryOperation = operationId()
expect(
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
operationId: retryOperation,
error: { recoverableOwner: 'tui' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'released',
handoffStage: 'old-owner-stopped'
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledOnce()
})
it('abandons a failed native acquisition and retries from the stopped TUI', async () => {
await moveToTui()
nativeAcquireFailure = new Error('native proof unavailable')
const retryOperation = operationId()
expect(
await submit(request('to-native', 'after-turn', { operationId: retryOperation }))
).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
operationId: retryOperation,
error: { recoverableOwner: 'tui' }
})
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'released',
handoffStage: 'old-owner-stopped'
})
coordinator = createCoordinator()
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'tui',
direction: 'to-native',
phase: 'failed',
operationId: retryOperation
})
expect(
await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation }))
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native'))
expect(waitForTuiExit).toHaveBeenCalledOnce()
expect(acquireNativeCalls).toBe(2)
})
it('publishes every durable stage so concurrent clients see the same transfer', async () => {
await moveToTui()
expect(statuses.map((status) => status.stage)).toEqual(
expect.arrayContaining(['preparing', 'old-owner-stopped', 'new-owner-proving', null])
)
expect(statuses.filter((status) => status.phase === 'switching')).toHaveLength(3)
})
}
)
@@ -0,0 +1,316 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions'
import {
admitStructuredHandoffRequest,
refuseAdmittedStructuredHandoff,
replayedStructuredHandoffRefusal,
structuredHandoffRetryIsAdmissible
} 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 { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart'
import {
structuredHandoffRefusal as refusal,
structuredHandoffSuccess
} from './structured-agent-session-handoff-result'
import {
failedStructuredHandoffStatus,
idleStructuredHandoffStatus,
structuredSessionHasPendingPrompt,
structuredTuiStatus,
switchingStructuredHandoffStatus
} from './structured-agent-session-handoff-status'
import type {
StructuredAgentSessionHandoffDeps,
StructuredAgentSessionHandoffFlowContext,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
export class StructuredAgentSessionHandoffCoordinator {
private readonly statuses = new Map<string, AgentSessionHandoffStatus>()
private readonly queue = new StructuredAgentSessionHandoffQueue()
private readonly tuiOwners = new Map<string, StructuredTuiOwner>()
private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard
private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner
constructor(private readonly deps: StructuredAgentSessionHandoffDeps) {
this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store)
this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({
deps,
operationGuard: this.operationGuard,
flowContext: () => this.flowContext(),
fail: (params, error) => this.fail(params, error)
})
}
status(sessionId: string): AgentSessionHandoffStatus {
return (
this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId))
)
}
drain = (): Promise<void> => this.flowRunner.drain()
async restore(sessionId: string): Promise<void> {
await restoreStructuredAgentSessionHandoff(
{
deps: this.deps,
requireRecord: (id) => this.requireRecord(id),
flowContext: () => this.flowContext(),
retainOwner: (id, owner) => this.tuiOwners.set(id, owner),
setStatus: (id, status) => this.setStatus(id, status)
},
sessionId
)
}
async request(
callerKey: string,
params: AgentSessionHandoffRequest
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
const record = this.requireRecord(params.envelope.sessionId)
const currentStatus = this.statuses.get(record.sessionId)
const admission = await admitStructuredHandoffRequest({
deps: this.deps,
operationGuard: this.operationGuard,
callerKey,
params,
record,
...(currentStatus ? { status: currentStatus } : {})
})
if (admission.decision === 'replay') {
const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome)
if (replayedRefusal) {
return { ok: false, refusal: replayedRefusal }
}
return this.success(record.sessionId, true)
}
if (admission.decision === 'refused') {
return { ok: false, refusal: admission.refusal }
}
const { fingerprint } = admission
const action = params.action ?? 'start'
if (action === 'cancel-queued') {
if (currentStatus?.phase !== 'queued' || currentStatus.direction !== params.direction) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_operation_conflict',
'No matching queued handoff exists.'
)
}
this.queue.cancel(record.sessionId)
this.setStatus(record.sessionId, idleStructuredHandoffStatus(record))
await this.deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'succeeded', sessionId: record.sessionId }
})
return this.success(record.sessionId, false)
}
if (!this.deps.transport) {
return this.refuseAdmitted(
callerKey,
params,
'structured_agent_session_unsupported',
'Agent TUI handoff is unavailable on this host.'
)
}
if (action === 'retry') {
if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_operation_conflict',
'This handoff is no longer retryable.'
)
}
this.begin(callerKey, params, null, fingerprint)
return this.success(record.sessionId, false)
}
const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui'
if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
`The ${expectedOwner} runtime does not own this session.`
)
}
if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
'Resolve the pending question or approval before switching.'
)
}
const turnId = activeStructuredAgentSessionTurnId(
this.deps.session(record.sessionId).journal.snapshot().items
)
const tuiOwner = this.tuiOwners.get(record.sessionId)
const busy =
expectedOwner === 'native'
? turnId !== null
: structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle'
if (busy && params.mode === 'now') {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_conflict',
'The current turn must finish before switching.'
)
}
if (busy && params.mode === 'after-turn') {
this.queueAfterTurn(callerKey, params, fingerprint)
return this.success(record.sessionId, false)
}
if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') {
return this.refuseAdmitted(
callerKey,
params,
'structured_agent_session_unsupported',
'Exit the agent terminal after this turn to continue in chat.'
)
}
this.begin(callerKey, params, turnId, fingerprint)
return this.success(record.sessionId, false)
}
private async refuseAdmitted(
callerKey: string,
params: AgentSessionHandoffRequest,
code: AgentSessionWireRefusal['code'],
message: string
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
return refuseAdmittedStructuredHandoff({
deps: this.deps,
callerKey,
params,
refusal: refusal(code, message)
})
}
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 begin(
callerKey: string,
params: AgentSessionHandoffRequest,
turnId: string | null,
fingerprint: string,
tuiAlreadyExited = false
): void {
this.flowRunner.begin({
callerKey,
params,
turnId,
fingerprint,
tuiAlreadyExited
})
}
private flowContext(): StructuredAgentSessionHandoffFlowContext {
return createStructuredHandoffFlowContext({
deps: this.deps,
owner: (sessionId) => this.tuiOwners.get(sessionId),
retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner),
releaseOwner: (sessionId) => void this.tuiOwners.delete(sessionId),
setStatus: (sessionId, status) => this.setStatus(sessionId, status),
enterPreparing: (record, operationId, direction) =>
this.enterPreparing(record, operationId, direction),
publishStage: (record, direction) => this.publishStage(record, direction),
requireRecord: (sessionId) => this.requireRecord(sessionId)
})
}
private async enterPreparing(
record: AgentSessionRecord,
operationId: string,
direction: 'to-tui' | 'to-native'
): Promise<void> {
const prepared = await setStoredAgentSessionHandoffStage(this.deps.store, {
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
stage: 'preparing',
handoffOperationId: operationId,
now: this.deps.now()
})
this.publishStage(prepared, direction)
}
private publishStage(record: AgentSessionRecord, direction: 'to-tui' | 'to-native'): void {
this.setStatus(
record.sessionId,
switchingStructuredHandoffStatus(record, direction, this.deps.transport?.hostLabel)
)
}
private fail(params: AgentSessionHandoffRequest, error: unknown): void {
const record = this.requireRecord(params.envelope.sessionId)
this.setStatus(
record.sessionId,
failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel)
)
}
private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
this.statuses.set(sessionId, status)
this.deps.publish(sessionId, status)
}
private requireRecord(sessionId: string): AgentSessionRecord {
const record = this.deps.store.getRecord(sessionId)
if (!record) {
throw new Error('agent_session_identity_required')
}
return record
}
}
@@ -0,0 +1,133 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
import { journalIdentityFor } from './structured-agent-session-attach'
import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart'
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
type HostHandoffAccess = {
session: (sessionId: string) => StructuredAgentSessionHostSession
eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink
flush: (sessionId: string) => Promise<void>
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
subscribers: AgentSessionSubscribers
now: () => number
}
export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator
export async function refreshRecoverableStructuredHandoffStatus(
handoff: StructuredAgentSessionHostHandoff,
store: StructuredAgentSessionHostDeps['store'],
sessionId: string
) {
const record = store.getRecord(sessionId)
if (record && canRestoreLiveTuiOwner(record)) {
await handoff.restore(sessionId)
}
return handoff.status(sessionId)
}
export function createStructuredAgentSessionHostHandoff(
deps: StructuredAgentSessionHostDeps,
host: HostHandoffAccess
): StructuredAgentSessionHostHandoff {
return new StructuredAgentSessionHandoffCoordinator({
store: deps.store,
claimKeyId: deps.claimKeyId,
...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}),
session: host.session,
suspendNative: async (sessionId) => {
await deps.adapter.closeSession?.(sessionId)
await host.flush(sessionId)
host.eventSink(sessionId).unbind()
},
acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input),
acquireNativeStop: async (sessionId, turnId, fence) =>
(await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled,
importTuiHistory: (input) => importTuiHistory(deps, host, input),
publish: (sessionId, status) => {
const session = host.session(sessionId)
const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence
host.subscribers.handoff(sessionId, session.journal, fence, status)
},
schedule: host.serialize,
now: host.now
})
}
async function importTuiHistory(
deps: StructuredAgentSessionHostDeps,
host: HostHandoffAccess,
input: { sessionId: string; fence: number; transcriptPath?: string }
): Promise<void> {
const session = host.session(input.sessionId)
const record = deps.store.getRecord(input.sessionId)
const head = record?.providerHandleChain.at(-1)
if (!record || !head) {
throw new Error('agent_session_identity_required')
}
const providerSessionId =
head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
const imported = await importLegacyTranscriptIntoJournal({
journal: session.journal,
agent: head.handle.provider,
sessionId: providerSessionId,
fence: input.fence,
...(input.transcriptPath ? { options: { filePath: input.transcriptPath } } : {})
})
if (!imported.ok) {
throw new Error(imported.error)
}
host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence)
}
async function acquireNativeHandoffOwner(
deps: StructuredAgentSessionHostDeps,
host: HostHandoffAccess,
input: { sessionId: string; fence: number; spawnToken: string }
): Promise<AgentSessionRecord> {
const session = host.session(input.sessionId)
const record = deps.store.getRecord(input.sessionId)
if (!record) {
throw new Error('agent_session_identity_required')
}
const eventSink = host.eventSink(input.sessionId)
eventSink.unbind()
await eventSink.drained()
const acquired = await deps.adapter.acquire({
identity: journalIdentityFor(record, session.params),
fence: input.fence,
spawnToken: input.spawnToken,
events: eventSink.sink
})
let proved: AgentSessionRecord
try {
await deps.store.commitProcessIdentity({
sessionId: input.sessionId,
fence: input.fence,
process: acquired.process,
now: host.now()
})
proved = await deps.store.proveOwner({
sessionId: input.sessionId,
fence: input.fence,
link: acquired.link,
now: host.now()
})
} catch (error) {
await deps.adapter.releaseAcquisition?.({ sessionId: input.sessionId })
throw error
}
session.fence = proved.lease.runtimeFence
eventSink.bind({
journal: session.journal,
fence: proved.lease.runtimeFence,
publish: () => host.subscribers.publish(input.sessionId, session.journal)
})
host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence)
return proved
}
@@ -0,0 +1,76 @@
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
createDeferredStructuredAgentSessionEventSink,
type DeferredStructuredAgentSessionEventSink
} from './structured-agent-session-event-sink'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer'
export class StructuredAgentSessionHostRuntimeState {
private readonly eventSinks = new Map<string, DeferredStructuredAgentSessionEventSink>()
private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer
constructor(
private readonly deps: StructuredAgentSessionHostDeps,
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>
) {
this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({
store: deps.store,
probe: (record) => this.probeRecord(record),
now: () => deps.now?.() ?? Date.now(),
...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}),
onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error })
})
}
startLeaseRenewal(): void {
this.leaseRenewer.start()
}
stopLeaseRenewal(): void {
this.leaseRenewer.stop()
}
eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink {
const existing = this.eventSinks.get(sessionId)
if (existing) {
return existing
}
const created = createDeferredStructuredAgentSessionEventSink({
onError: (error) => this.deps.onEventSinkError?.({ sessionId, error })
})
this.eventSinks.set(sessionId, created)
return created
}
discardEventSink(sessionId: string): void {
this.eventSinks.delete(sessionId)
}
flushEventSink(sessionId: string): Promise<void> {
return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve()
}
async flushAllEventSinks(): Promise<void> {
await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained()))
}
probeOwner(sessionId: string): Promise<AgentSessionOwnerProbe> {
const record = this.deps.store.getRecord(sessionId)
if (!record || record.lease.ownerProcess === null) {
return Promise.resolve({ outcome: 'reservation-unused' })
}
return this.probeRecord(record)
}
probeRecord(record: AgentSessionRecord): Promise<AgentSessionOwnerProbe> {
return (
this.deps.probeOwner?.(record) ??
Promise.resolve({
outcome: 'indeterminate',
reason: 'This host cannot probe structured session owners.'
})
)
}
}
@@ -0,0 +1,15 @@
export type StructuredAgentSessionTab = {
sessionId: string
workspaceId: string
agent: 'codex'
}
export function listStructuredAgentSessionTabs(
sessions: ReadonlyMap<string, { params: { location: { workspaceId: string } } }>
): StructuredAgentSessionTab[] {
return [...sessions.entries()].map(([sessionId, session]) => ({
sessionId,
workspaceId: session.params.location.workspaceId,
agent: 'codex'
}))
}
@@ -0,0 +1,63 @@
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import { attachFingerprintFields } from './structured-agent-session-attach'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
export const HOST_TEST_NOW = 1_800_000_000_000
export const HOST_TEST_SESSION = 'session-alpha'
export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
}
let operations = 0
export function resetHostTestOperationIds(): void {
operations = 0
}
export function hostTestOperationId(): string {
operations += 1
return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}`
}
export function hostTestMessage(text: string): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}
export function hostTestAttachParams(
expectedRuntimeFence: number | null,
overrides: Partial<AgentSessionAttachParams> = {}
): AgentSessionAttachParams {
const params: AgentSessionAttachParams = {
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence,
payloadFingerprint: '0'.repeat(64)
},
location: HOST_TEST_LOCATION,
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD },
...overrides
}
return {
...params,
envelope: {
...params.envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: params.envelope.sessionId,
fields: attachFingerprintFields(params)
})
}
}
}
@@ -0,0 +1,27 @@
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 }
export type StructuredAgentSessionHostSession = {
journal: AgentSessionJournal
params: AgentSessionAttachParams
fence: number
}
export type StructuredAgentSessionHostDeps = {
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
claimKeyId: string
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
mintSpawnToken?: () => string
now?: () => number
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
@@ -1,19 +1,11 @@
// The host against a real record store and a real journal, with only the
// provider adapter stubbed — admission, idempotency, and the journal writes are
// exactly the ones that ship.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type {
AgentSessionExecutionLocation,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionMutationEnvelope,
AgentSessionSubscribeEvent
@@ -28,35 +20,20 @@ import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionAdapter
} from './structured-agent-session-adapter'
import { attachFingerprintFields } from './structured-agent-session-attach'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
const NOW = 1_800_000_000_000
const SESSION = 'session-alpha'
const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
const LOCATION: AgentSessionExecutionLocation = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
}
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const CALLER = { callerKey: 'client-1' }
let operations = 0
/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */
function operationId(): string {
operations += 1
return `${NOW}-${operations.toString(16).padStart(32, '0')}`
}
function message(text: string): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}
function envelope(
method: string,
fields: Record<string, unknown>,
@@ -64,9 +41,7 @@ function envelope(
): AgentSessionMutationEnvelope {
return {
sessionId: SESSION,
clientOperationId: operationId(),
// A mutation names the fence it believes it is writing under; only attach
// may leave it null.
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
@@ -78,44 +53,11 @@ function envelope(
}
function attachParams(overrides: Partial<AgentSessionAttachParams> = {}): AgentSessionAttachParams {
const params: AgentSessionAttachParams = {
envelope: {
sessionId: SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: null,
payloadFingerprint: '0'.repeat(64)
},
location: LOCATION,
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: THREAD },
...overrides
}
return {
...params,
envelope: {
...params.envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: params.envelope.sessionId,
fields: attachFingerprintFields(params)
})
}
}
return hostTestAttachParams(null, overrides)
}
/** `ensure`: the same transition as create, from a fence the client already holds. */
function ensureParams(fence: number): AgentSessionAttachParams {
return attachParams({
envelope: {
sessionId: SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: fence,
payloadFingerprint: '0'.repeat(64)
}
})
return hostTestAttachParams(fence)
}
let root: string
@@ -185,7 +127,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-host-'))
operations = 0
resetHostTestOperationIds()
ordinal = 0
acquire = vi.fn(async ({ fence }) => ({
process: {
@@ -197,8 +139,6 @@ beforeEach(async () => {
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
// A restarted host re-proves the thread it inherited; only the first
// owner of a session may claim to have created it.
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: NOW
@@ -221,6 +161,7 @@ beforeEach(async () => {
})
afterEach(async () => {
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
})
@@ -228,7 +169,6 @@ describe('attach', () => {
it('reserves the lease, spawns through the adapter, and opens the journal', async () => {
const result = await host.attach(CALLER, attachParams())
expect(result).toMatchObject({ ok: true, replayed: false })
expect(host.hasSession(SESSION)).toBe(true)
const record = store.getRecord(SESSION)
expect(record?.lease.ownerProcess?.pid).toBe(4242)
expect(record?.lease.handoffStage).toBeNull()
@@ -409,7 +349,7 @@ describe('attach', () => {
describe('send', () => {
it('writes the submission before dispatching and resolves it accepted', async () => {
await attach()
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
@@ -427,7 +367,7 @@ describe('send', () => {
it('settles a thrown dispatch as unknown, never as a rejection', async () => {
await attach()
dispatch.mockRejectedValueOnce(new Error('socket closed'))
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
@@ -437,7 +377,7 @@ describe('send', () => {
it('replays a retried send from the journal without dispatching twice', async () => {
await attach()
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const params = { envelope: envelope('agentSession.send', { body }), body }
await host.send(CALLER, params)
const retry = await host.send(CALLER, params)
@@ -450,7 +390,7 @@ describe('send', () => {
dispatch
.mockRejectedValueOnce(new Error('socket closed'))
.mockImplementationOnce(async () => accepted())
const body = message('possibly delivered')
const body = hostTestMessage('possibly delivered')
const params = { envelope: envelope('agentSession.send', { body }), body }
const first = await host.send(CALLER, params)
@@ -472,7 +412,7 @@ describe('send', () => {
it('refuses a stale fence and hands back the current one', async () => {
const record = await attach()
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const result = await host.send(CALLER, {
envelope: envelope(
'agentSession.send',
@@ -489,7 +429,7 @@ describe('send', () => {
it('does not let a refused call leave a ledger row that replays past the fence', async () => {
const record = await attach()
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const params = {
envelope: envelope(
'agentSession.send',
@@ -507,7 +447,7 @@ describe('send', () => {
})
it('refuses any mutation against a session this host has not attached', async () => {
const body = message('add a retry')
const body = hostTestMessage('add a retry')
expect(
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } })
@@ -738,7 +678,7 @@ describe('restart', () => {
it('restores durable journals for read-only history without acquiring a provider', async () => {
await attach()
const body = message('persisted conversation')
const body = hostTestMessage('persisted conversation')
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' }))
acquire.mockClear()
@@ -753,13 +693,40 @@ describe('restart', () => {
expect(acquire).not.toHaveBeenCalled()
})
it('clears stale TUI recovery when restart successfully reacquires the native owner', async () => {
await attach()
await store.transitionHandoff(SESSION, (record) => ({
...record,
lease: {
...record.lease,
runtimeKind: 'tui',
handoffStage: 'manual-recovery'
}
}))
await reboot(async () => ({ outcome: 'pid-absent' }))
acquire.mockClear()
await host.restoreReadableSessions()
expect(acquire).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({
owner: 'native',
phase: 'idle',
stage: null
})
})
it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => {
await attach()
const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' }))
// Nothing proved the previous process dead, so the lease routes to manual
// recovery rather than letting a second writer in behind it.
expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({
ok: false,
refusal: { code: 'agent_session_ownership_unknown' }
@@ -776,7 +743,6 @@ describe('restart', () => {
await reboot(probe)
await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded')
// One unlucky startup must not strand the session for this host's lifetime.
const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))
expect(reattached).toMatchObject({ ok: true })
expect(probe).toHaveBeenCalledTimes(2)
@@ -792,7 +758,7 @@ describe('subscribe', () => {
sessionId: SESSION,
emit: (event) => events.push(event)
})
const body = message('add a retry')
const body = hostTestMessage('add a retry')
await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
expect(events[0]?.type).toBe('snapshot')
@@ -807,7 +773,7 @@ describe('subscribe', () => {
it('resumes from a client cursor with only the rows it missed', async () => {
await attach()
const body = message('add a retry')
const body = hostTestMessage('add a retry')
const first = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
body
@@ -823,10 +789,9 @@ describe('subscribe', () => {
emit: (event) => events.push(event),
cursor: first.cursor
})
// Nothing new since that cursor: a resume is silent, not a redundant replay.
expect(events).toHaveLength(0)
expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } })
const second = message('and a timeout')
const second = hostTestMessage('and a timeout')
await host.send(CALLER, {
envelope: envelope('agentSession.send', { body: second }),
body: second
@@ -850,7 +815,7 @@ describe('subscribe', () => {
sessionId: SESSION,
emit: (event) => events.push(event)
})
const body = message('survive subscriber failure')
const body = hostTestMessage('survive subscriber failure')
const result = await host.send(CALLER, {
envelope: envelope('agentSession.send', { body }),
@@ -1,24 +1,16 @@
// The structured agent-session host: one place where the lease, the journal,
// and the provider adapter meet.
//
// Every mutating call takes the same route — recompute the fingerprint, admit
// through the durable operation ledger, check the lease, then act — so no
// method can grow its own admission rules. Calls against one session are
// serialized; the journal's own queue orders writes, but admission has to be
// single-file too or two clients could both read one prompt as pending.
// Structured agent-session host: where the lease, journal, and provider adapter meet.
// Mutations share one durable admission path and serialize per session.
import { randomUUID } from 'node:crypto'
import type { AgentJournalCursor } 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,
AgentSessionHistoryRequest,
AgentSessionHistoryResult,
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationEnvelope,
AgentSessionMutationResult,
AgentSessionOptionResult,
@@ -27,16 +19,9 @@ import type {
AgentSessionSendResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-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 {
createDeferredStructuredAgentSessionEventSink,
type DeferredStructuredAgentSessionEventSink
} from './structured-agent-session-event-sink'
import {
admitAndRunAgentSessionMutation,
AGENT_SESSION_NOT_ATTACHED,
@@ -53,59 +38,59 @@ import {
} from './structured-agent-session-mutation-plans'
import {
AgentSessionSubscribers,
type AgentSessionSubscriberEmit
type AgentSessionSubscribeInput
} from './structured-agent-session-subscribers'
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
import * as providerRouting from './structured-agent-session-provider-routing'
import {
createStructuredAgentSessionHostHandoff,
refreshRecoverableStructuredHandoffStatus,
type StructuredAgentSessionHostHandoff
} from './structured-agent-session-host-handoff'
import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
export type StructuredAgentSessionCaller = {
/** Stable per-client identity; scopes the operation ledger and records who
* answered a prompt. */
callerKey: string
}
export type StructuredAgentSessionHostDeps = {
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
/** Key id the host's claims are minted under. */
claimKeyId: string
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
mintSpawnToken?: () => string
now?: () => number
/** A journal append the provider streamed that could not be written. Unset
* drops it: the alternative is throwing inside the provider's notification
* callback, which would take the connection down over one lost row. */
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
}
type SessionState = {
journal: AgentSessionJournal
params: AgentSessionAttachParams
fence: number
}
export type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps
} from './structured-agent-session-host-types'
export class StructuredAgentSessionHost {
private readonly sessions = new Map<string, SessionState>()
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
private readonly subscribers = new AgentSessionSubscribers()
private readonly tasks = new StructuredAgentSessionTaskQueue()
private readonly eventSinks = new Map<string, DeferredStructuredAgentSessionEventSink>()
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
private readonly reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
private readonly handoffs: StructuredAgentSessionHostHandoff
constructor(readonly deps: StructuredAgentSessionHostDeps) {
this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) =>
this.restoreRenewedHandoff(record.sessionId)
)
this.reconcileLeases = createRestartReconciler({
store: deps.store,
probe: (record) => this.probeRecord(record),
probe: (record) => this.runtimeState.probeRecord(record),
now: () => this.now()
})
this.handoffs = createStructuredAgentSessionHostHandoff(deps, {
session: (sessionId) => this.requireSession(sessionId),
eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId),
flush: (sessionId) => this.flushStreamedEvents(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
subscribers: this.subscribers,
now: this.now
})
this.runtimeState.startLeaseRenewal()
}
private now = (): number => this.deps.now?.() ?? Date.now()
hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId)
}
hasSession = (sessionId: string): boolean => this.sessions.has(sessionId)
supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean {
return providerRouting.adapterSupportsAgentSessionCreate(this.deps.adapter, location, agent)
@@ -137,7 +122,8 @@ export class StructuredAgentSessionHost {
),
serialize: (sessionId, task) => this.serialize(sessionId, task),
hasSession: (sessionId) => this.sessions.has(sessionId),
onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored)
onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored),
restoreHandoff: (sessionId) => this.handoffs.restore(sessionId)
})
}
@@ -145,10 +131,14 @@ export class StructuredAgentSessionHost {
return this.tasks.serialize(sessionId, task)
}
/**
* `create` and `ensure` in one: a null expected fence means the session must
* not exist yet, any other value is a compare-and-swap against the lease.
*/
private restoreRenewedHandoff(sessionId: string): Promise<void> {
return this.serialize(sessionId, async () => {
if (this.sessions.has(sessionId)) {
await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId)
}
})
}
attach(
caller: StructuredAgentSessionCaller,
params: AgentSessionAttachParams
@@ -159,7 +149,7 @@ export class StructuredAgentSessionHost {
if (unreconciled) {
return refuseAgentSessionMutation(unreconciled)
}
const eventSink = this.eventSinkFor(sessionId)
const eventSink = this.runtimeState.eventSinkFor(sessionId)
const attached = await performAttach({
store: this.deps.store,
adapter: this.deps.adapter,
@@ -174,7 +164,7 @@ export class StructuredAgentSessionHost {
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
claimKeyId: this.deps.claimKeyId,
handoffOperationId: params.envelope.clientOperationId,
probe: await this.probeOwner(sessionId)
probe: await this.runtimeState.probeOwner(sessionId)
},
callerKey: caller.callerKey,
params,
@@ -190,9 +180,6 @@ export class StructuredAgentSessionHost {
} else {
this.subscribers.publish(sessionId, attached.journal)
}
// Bound last: everything the provider streamed while the journal was
// opening lands after the attach result the client is about to read,
// never interleaved with it.
eventSink.bind({
journal: attached.journal,
fence,
@@ -200,66 +187,23 @@ export class StructuredAgentSessionHost {
})
}
})
// A refused attach that never reached a journal leaves the sink holding
// writes nothing will ever drain.
if (!attached.ok && !this.sessions.has(sessionId)) {
eventSink.close()
this.eventSinks.delete(sessionId)
this.runtimeState.discardEventSink(sessionId)
}
return attached
})
return this.tasks.trackAttach(attaching)
}
/** Settles every row the provider streamed. Callers that must read the
* journal immediately after provider activity await this first. */
flushStreamedEvents(sessionId: string): Promise<void> {
return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve()
return this.runtimeState.flushEventSink(sessionId)
}
/** Settles final provider rows after every child has stopped producing them. */
async flushAllStreamedEvents(): Promise<void> {
// An acquired provider can still be opening its journal; bind before draining.
this.runtimeState.stopLeaseRenewal()
await this.tasks.drainAttaches()
await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained()))
}
/** One sink per session, rebound on every attach. A single identity lets an
* adapter hold it across a re-acquire without noticing the new journal. */
private eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink {
const existing = this.eventSinks.get(sessionId)
if (existing) {
return existing
}
const created = createDeferredStructuredAgentSessionEventSink({
onError: (error) => this.deps.onEventSinkError?.({ sessionId, error })
})
this.eventSinks.set(sessionId, created)
return created
}
/**
* Liveness of whatever currently owns the session. Never client-asserted: a
* client that could claim the owner is gone could evict a live one. Without a
* host prober the answer is `indeterminate`, which routes to manual recovery
* rather than a silent takeover.
*/
private probeOwner(sessionId: string): Promise<AgentSessionOwnerProbe> {
const record = this.deps.store.getRecord(sessionId)
if (!record || record.lease.ownerProcess === null) {
return Promise.resolve({ outcome: 'reservation-unused' })
}
return this.probeRecord(record)
}
private probeRecord(record: AgentSessionRecord): Promise<AgentSessionOwnerProbe> {
return (
this.deps.probeOwner?.(record) ??
Promise.resolve({
outcome: 'indeterminate',
reason: 'This host cannot probe structured session owners.'
})
)
await this.runtimeState.flushAllEventSinks()
}
send(
@@ -325,11 +269,21 @@ export class StructuredAgentSessionHost {
)
}
/**
* Paged read. Throws `agent_session_ownership_unknown` for a session this host
* has not attached — a reset would tell the client to reload something that
* does not exist here.
*/
requestHandoff(
caller: StructuredAgentSessionCaller,
params: AgentSessionHandoffRequest
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> {
this.requireSession(params.envelope.sessionId)
return this.handoffs.request(caller.callerKey, params)
}
async handoffStatus(sessionId: string): Promise<AgentSessionHandoffStatus> {
this.requireSession(sessionId)
return this.serialize(sessionId, () =>
refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId)
)
}
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request)
const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence
@@ -339,20 +293,20 @@ export class StructuredAgentSessionHost {
return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence }
}
subscribe(input: {
id: string
sessionId: string
emit: AgentSessionSubscriberEmit
cursor?: AgentJournalCursor
}): () => void {
subscribe(input: AgentSessionSubscribeInput): () => void {
const session = this.requireSession(input.sessionId)
const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0
return this.subscribers.open({ ...input, journal: session.journal, fence })
return this.subscribers.open({
...input,
journal: session.journal,
fence,
handoff: this.handoffs.status(input.sessionId)
})
}
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
private requireSession(sessionId: string): SessionState {
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error(AGENT_SESSION_NOT_ATTACHED.code)
@@ -0,0 +1,135 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer'
const NOW = 1_800_000_000_000
const roots: string[] = []
async function liveStore(): Promise<AgentSessionRecordStore> {
const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-'))
roots.push(root)
const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' })
const reserved = await store.reserveOwner({
sessionId: 'session-renewal',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: root },
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'spawn-renewal',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: {
callerKey: 'test',
operationId: `${NOW}-00000000000000000000000000000001`,
fingerprint: 'create'
},
now: NOW
})
await store.commitProcessIdentity({
sessionId: 'session-renewal',
fence: reserved.record.lease.runtimeFence,
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: NOW - 1_000,
spawnToken: 'spawn-renewal'
},
now: NOW
})
await store.proveOwner({
sessionId: 'session-renewal',
fence: reserved.record.lease.runtimeFence,
link: {
linkId: 'link-renewal',
handle: { provider: 'codex', threadId: 'thread-renewal' },
origin: 'created',
mintedAtFence: reserved.record.lease.runtimeFence,
observedAt: NOW
},
now: NOW
})
return store
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('structured agent-session lease renewal', () => {
it('drives renewal on the production interval', async () => {
vi.useFakeTimers()
const store = await liveStore()
let now = NOW
const renewer = new StructuredAgentSessionLeaseRenewer({
store,
probe: async () => ({
outcome: 'identity-matched',
matchedOn: ['process-start-time']
}),
now: () => now
})
try {
renewer.start()
now += 10_000
await vi.advanceTimersByTimeAsync(10_000)
await vi.waitFor(() =>
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now)
)
} finally {
renewer.stop()
vi.useRealTimers()
}
})
it('renews every live owner only after re-proving its child identity', async () => {
const store = await liveStore()
const probe = vi.fn(async () => ({
outcome: 'identity-matched' as const,
matchedOn: ['process-start-time' as const]
}))
const onRenewed = vi.fn()
const renewer = new StructuredAgentSessionLeaseRenewer({
store,
probe,
now: () => NOW + 10_000,
onRenewed
})
await renewer.renewNow()
expect(probe).toHaveBeenCalledOnce()
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000)
expect(onRenewed).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'session-renewal' })
)
})
it('stops extending the lease when child proof is no longer sufficient', async () => {
const store = await liveStore()
const onError = vi.fn()
const renewer = new StructuredAgentSessionLeaseRenewer({
store,
probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }),
now: () => NOW + 10_000,
onError
})
await renewer.renewNow()
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW)
expect(onError).toHaveBeenCalledWith({
sessionId: 'session-renewal',
error: expect.any(Error)
})
})
})
@@ -0,0 +1,75 @@
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
AGENT_SESSION_LEASE_TTL_MS,
type AgentSessionRecordStore
} from '../../runtime/agent-session-record-store'
const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3)
export class StructuredAgentSessionLeaseRenewer {
private timer: ReturnType<typeof setInterval> | null = null
private running = false
constructor(
private readonly input: {
store: AgentSessionRecordStore
probe: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
now: () => number
onRenewed?: (record: AgentSessionRecord) => Promise<void>
onError?: (input: { sessionId: string; error: unknown }) => void
intervalMs?: number
}
) {}
start(): void {
if (this.timer) {
return
}
this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS)
this.timer.unref?.()
}
stop(): void {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
async renewNow(): Promise<void> {
if (this.running) {
return
}
this.running = true
try {
await Promise.all(
this.input.store
.listRecords()
.filter(
(record) =>
!record.lease.unreconciled &&
record.lease.claimStatus === 'live' &&
record.lease.ownerProcess !== null
)
.map((record) => this.renewRecord(record))
)
} finally {
this.running = false
}
}
private async renewRecord(record: AgentSessionRecord): Promise<void> {
try {
const renewed = await this.input.store.renewLease({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
childProbe: await this.input.probe(record),
now: this.input.now()
})
await this.input.onRenewed?.(renewed)
} catch (error) {
this.input.onError?.({ sessionId: record.sessionId, error })
}
}
}
@@ -1,4 +1,7 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { loadJournal } from '../agent-session-journal/journal-open'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
@@ -49,7 +52,11 @@ export async function restoreStructuredAgentSessionRead(
export function attachParamsForRecord(
record: AgentSessionRecord,
input: { clientOperationId: string; expectedRuntimeFence: number }
input: {
clientOperationId: string
expectedRuntimeFence: number
runtimeKind?: AgentSessionOwnerRuntimeKind
}
): AgentSessionAttachParams {
const params: AgentSessionAttachParams = {
envelope: {
@@ -62,7 +69,7 @@ export function attachParamsForRecord(
provider: record.provider,
agent: record.provider,
accountHome: record.accountHome,
runtimeKind: record.lease.runtimeKind
runtimeKind: input.runtimeKind ?? record.lease.runtimeKind
}
return {
...params,
@@ -18,6 +18,7 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
restoreHandoff: (sessionId: string) => Promise<void>
}): Promise<void> {
await Promise.all(
input.records.map(async ({ sessionId }) => {
@@ -26,10 +27,12 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
if (
!unreconciled &&
current?.lease.claimStatus === 'released' &&
current.lease.handoffStage === null &&
(await input.resume(
attachParamsForRecord(current, {
clientOperationId: input.operationId(),
expectedRuntimeFence: current.lease.runtimeFence
expectedRuntimeFence: current.lease.runtimeFence,
runtimeKind: 'native'
})
))
) {
@@ -48,6 +51,9 @@ export async function restoreStructuredAgentSessionsOnRestart(input: {
input.onReadable(sessionId, restored)
}
})
if (input.hasSession(sessionId)) {
await input.restoreHandoff(sessionId)
}
})
)
}
@@ -8,11 +8,20 @@ import type {
AgentJournalCursor,
AgentJournalResetReason
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
import type {
AgentSessionHandoffStatus,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { projectJournalBatch } from './agent-session-journal-batch'
export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void
export type AgentSessionSubscribeInput = {
id: string
sessionId: string
emit: AgentSessionSubscriberEmit
cursor?: AgentJournalCursor
}
type Subscriber = {
id: string
@@ -34,6 +43,7 @@ export class AgentSessionSubscribers {
fence: number
emit: AgentSessionSubscriberEmit
cursor?: AgentJournalCursor
handoff?: AgentSessionHandoffStatus
}): () => void {
const snapshot = input.journal.snapshot()
const subscriber: Subscriber = {
@@ -48,13 +58,14 @@ export class AgentSessionSubscribers {
this.bySession.set(input.sessionId, session)
if (input.cursor) {
this.deliver(subscriber, input.journal)
this.deliver(subscriber, input.journal, input.handoff)
} else {
this.emit(subscriber, {
type: 'snapshot',
sessionId: input.sessionId,
snapshot,
fence: input.fence
fence: input.fence,
...(input.handoff ? { handoff: input.handoff } : {})
})
subscriber.cursor = snapshot.cursor
}
@@ -107,11 +118,29 @@ export class AgentSessionSubscribers {
}
}
handoff(
sessionId: string,
journal: AgentSessionJournal,
fence: number,
handoff: AgentSessionHandoffStatus
): void {
const snapshot = journal.snapshot()
for (const subscriber of this.subscribers(sessionId)) {
this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence, handoff })
subscriber.cursor = snapshot.cursor
subscriber.fence = fence
}
}
private subscribers(sessionId: string): Subscriber[] {
return [...(this.bySession.get(sessionId)?.values() ?? [])]
}
private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void {
private deliver(
subscriber: Subscriber,
journal: AgentSessionJournal,
handoff?: AgentSessionHandoffStatus
): void {
const since = journal.readSince(subscriber.cursor)
const snapshot = journal.snapshot()
if (!since.ok) {
@@ -120,12 +149,27 @@ export class AgentSessionSubscribers {
sessionId: subscriber.sessionId,
reset: since.reset,
snapshot,
fence: subscriber.fence
fence: subscriber.fence,
...(handoff ? { handoff } : {})
})
subscriber.cursor = snapshot.cursor
return
}
if (since.rows.length === 0) {
if (handoff) {
this.emit(subscriber, {
type: 'batch',
sessionId: subscriber.sessionId,
batch: {
cursor: snapshot.cursor,
items: [],
removedItemIds: [],
submissions: []
},
fence: subscriber.fence,
handoff
})
}
return
}
const projected = projectJournalBatch({
@@ -139,7 +183,8 @@ export class AgentSessionSubscribers {
sessionId: subscriber.sessionId,
reset: projected.reset,
snapshot,
fence: subscriber.fence
fence: subscriber.fence,
...(handoff ? { handoff } : {})
})
subscriber.cursor = snapshot.cursor
return
@@ -147,7 +192,9 @@ export class AgentSessionSubscribers {
this.emit(subscriber, {
type: 'batch',
sessionId: subscriber.sessionId,
batch: projected.batch
batch: projected.batch,
...(handoff ? { fence: subscriber.fence } : {}),
...(handoff ? { handoff } : {})
})
subscriber.cursor = projected.batch.cursor
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions'
describe('agent session handoff restart transitions', () => {
it('preserves the stopped owner and operation for durable retry', () => {
const handoffOperationId = '1800000000000-00000000000000000000000000000001'
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({
runtimeKind: 'native',
runtimeFence: 4,
handoffStage: 'old-owner-stopped',
handoffOperationId,
claimStatus: 'released',
ownerProcess: null,
reservedSpawnToken: null,
unreconciled: true
})
)
const next = applyAgentSessionRestartAdjudication({
record,
probe: { outcome: 'reservation-unused' },
now: 1_800_000_001_000
})
expect(next.lease).toMatchObject({
runtimeKind: 'native',
runtimeFence: 4,
handoffStage: 'old-owner-stopped',
handoffOperationId,
claimStatus: 'released',
ownerProcess: null,
unreconciled: false
})
})
})
@@ -0,0 +1,155 @@
import type {
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from '../../shared/agent-session-record'
import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions'
export function stopAgentSessionOwnerForHandoff(args: {
record: AgentSessionRecord
expectedFence: number
operationId: string
now: number
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
if (
record.lease.handoffStage !== 'preparing' ||
record.lease.handoffOperationId !== args.operationId ||
record.lease.ownerProcess === null
) {
throw new Error('agent_session_ownership_unknown')
}
return withLease(record, {
...record.lease,
runtimeFence: record.lease.runtimeFence + 1,
handoffStage: 'old-owner-stopped',
ownerProcess: null,
reservedSpawnToken: null,
processlessAt: null,
claimStatus: 'released',
lastRenewedAt: args.now,
deathEvidence: {
kind: 'exit-observed',
detail: 'observed process exit',
observedAt: args.now
}
})
}
export function rollbackAgentSessionHandoffPreparation(args: {
record: AgentSessionRecord
expectedFence: number
operationId: string
now: number
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
if (
record.lease.handoffStage !== 'preparing' ||
record.lease.handoffOperationId !== args.operationId ||
record.lease.claimStatus !== 'live' ||
record.lease.ownerProcess === null
) {
throw new Error('agent_session_ownership_unknown')
}
return withLease(record, {
...record.lease,
handoffStage: null,
handoffOperationId: null,
lastRenewedAt: args.now
})
}
export function stopRecoveringTuiOwnerForHandoff(args: {
record: AgentSessionRecord
expectedFence: number
operationId: string
now: number
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
if (
(record.lease.handoffStage !== 'recovering' &&
record.lease.handoffStage !== 'manual-recovery') ||
record.lease.runtimeKind !== 'tui' ||
record.lease.handoffOperationId !== null ||
record.lease.claimStatus !== 'live' ||
record.lease.ownerProcess === null
) {
throw new Error('agent_session_ownership_unknown')
}
return withLease(record, {
...record.lease,
runtimeFence: record.lease.runtimeFence + 1,
handoffStage: 'old-owner-stopped',
handoffOperationId: args.operationId,
ownerProcess: null,
reservedSpawnToken: null,
processlessAt: null,
claimStatus: 'released',
lastRenewedAt: args.now,
deathEvidence: {
kind: 'exit-observed',
detail: 'recovery proved TUI process exit',
observedAt: args.now
}
})
}
export function reserveAgentSessionHandoffOwner(args: {
record: AgentSessionRecord
expectedFence: number
runtimeKind: AgentSessionOwnerRuntimeKind
spawnToken: string
operationId: string
claimKeyId: string
now: number
leaseTtlMs: number
}): AgentSessionRecord {
return reserveAgentSessionOwner({
record: args.record,
expectedFence: args.expectedFence,
probe: { outcome: 'reservation-unused' },
reservation: {
runtimeKind: args.runtimeKind,
spawnToken: args.spawnToken,
claimKeyId: args.claimKeyId,
handoffOperationId: args.operationId,
leaseTtlMs: args.leaseTtlMs,
now: args.now
}
}).record
}
export function abandonAgentSessionHandoffAttempt(args: {
record: AgentSessionRecord
expectedFence: number
operationId: string
recoverableRuntimeKind: AgentSessionOwnerRuntimeKind
now: number
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
if (
record.lease.handoffStage !== 'new-owner-proving' ||
record.lease.handoffOperationId !== args.operationId
) {
throw new Error('agent_session_ownership_unknown')
}
return withLease(record, {
...record.lease,
runtimeKind: args.recoverableRuntimeKind,
runtimeFence: record.lease.runtimeFence + 1,
handoffStage: 'old-owner-stopped',
ownerProcess: null,
reservedSpawnToken: null,
processlessAt: null,
claimStatus: 'released',
lastRenewedAt: args.now,
deathEvidence: {
kind: 'exit-observed',
detail: 'handoff launch attempt stopped',
observedAt: args.now
}
})
}
@@ -0,0 +1,92 @@
import type { AgentSessionHandoffStage } from '../../shared/agent-session-record'
import {
abandonAgentSessionHandoffAttempt,
reserveAgentSessionHandoffOwner,
rollbackAgentSessionHandoffPreparation,
stopAgentSessionOwnerForHandoff,
stopRecoveringTuiOwnerForHandoff
} from './agent-session-handoff-lease-transitions'
import { setAgentSessionHandoffStage } from './agent-session-lease-transitions'
import {
AGENT_SESSION_LEASE_TTL_MS,
type AgentSessionRecordStore
} from './agent-session-record-store'
export function setStoredAgentSessionHandoffStage(
store: AgentSessionRecordStore,
args: {
sessionId: string
fence: number
stage: AgentSessionHandoffStage | null
handoffOperationId: string | null
now: number
}
) {
return store.transitionHandoff(args.sessionId, (record) =>
setAgentSessionHandoffStage({ ...args, record })
)
}
export function stopStoredAgentSessionOwnerForHandoff(
store: AgentSessionRecordStore,
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
) {
return store.transitionHandoff(args.sessionId, (record) =>
stopAgentSessionOwnerForHandoff({ ...args, record })
)
}
export function rollbackStoredAgentSessionHandoffPreparation(
store: AgentSessionRecordStore,
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
) {
return store.transitionHandoff(args.sessionId, (record) =>
rollbackAgentSessionHandoffPreparation({ ...args, record })
)
}
export function stopStoredRecoveringTuiOwnerForHandoff(
store: AgentSessionRecordStore,
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
) {
return store.transitionHandoff(args.sessionId, (record) =>
stopRecoveringTuiOwnerForHandoff({ ...args, record })
)
}
export function reserveStoredAgentSessionHandoffOwner(
store: AgentSessionRecordStore,
args: {
sessionId: string
expectedFence: number
runtimeKind: 'native' | 'tui'
spawnToken: string
operationId: string
claimKeyId: string
now: number
leaseTtlMs?: number
}
) {
return store.transitionHandoff(args.sessionId, (record) =>
reserveAgentSessionHandoffOwner({
...args,
record,
leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS
})
)
}
export function abandonStoredAgentSessionHandoffAttempt(
store: AgentSessionRecordStore,
args: {
sessionId: string
expectedFence: number
operationId: string
recoverableRuntimeKind: 'native' | 'tui'
now: number
}
) {
return store.transitionHandoff(args.sessionId, (record) =>
abandonAgentSessionHandoffAttempt({ ...args, record })
)
}
@@ -6,7 +6,6 @@
* observed process identity, and a proved provider handle — in that order, at one fence.
*/
import { isDeepStrictEqual } from 'node:util'
import {
adjudicateAgentSessionRestart,
evaluateAgentSessionAcquisition,
@@ -24,6 +23,7 @@ import type {
AgentSessionProcessIdentity,
AgentSessionRecord
} from '../../shared/agent-session-record'
import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication'
export type AgentSessionReservation = {
runtimeKind: AgentSessionOwnerRuntimeKind
@@ -34,11 +34,14 @@ export type AgentSessionReservation = {
now: number
}
function withLease(record: AgentSessionRecord, lease: AgentSessionLease): AgentSessionRecord {
export function withLease(
record: AgentSessionRecord,
lease: AgentSessionLease
): AgentSessionRecord {
return { ...record, lease, updatedAt: lease.lastRenewedAt }
}
function assertFence(lease: AgentSessionLease, fence: number): void {
export function assertFence(lease: AgentSessionLease, fence: number): void {
if (lease.runtimeFence !== fence) {
throw new Error('agent_session_checkpoint_stale')
}
@@ -47,13 +50,6 @@ function assertFence(lease: AgentSessionLease, fence: number): void {
}
}
export function agentSessionReconciliationTargetMatches(
current: AgentSessionRecord,
probed: AgentSessionRecord
): boolean {
return isDeepStrictEqual(current, probed)
}
/**
* Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the
* loser of a concurrent swap is refused and never spawns.
@@ -242,6 +238,23 @@ export function applyAgentSessionRestartAdjudication(args: {
now: number
}): AgentSessionRecord {
const { record } = args
if (
record.lease.handoffStage === 'old-owner-stopped' &&
record.lease.claimStatus === 'released' &&
record.lease.ownerProcess === null
) {
return withLease(record, {
...record.lease,
unreconciled: false,
lastRenewedAt: args.now
})
}
if (
record.lease.handoffStage === 'preparing' ||
record.lease.handoffStage === 'new-owner-proving'
) {
return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now)
}
const adjudication = adjudicateAgentSessionRestart({
lease: record.lease,
probe: args.probe,
@@ -154,13 +154,18 @@ describe('owner identity probe', () => {
).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })
})
it('returns no start time on Windows rather than guessing one', async () => {
await expect(readProcessStartTimeMs(process.pid, 'win32')).resolves.toBeNull()
it('reads a CIM-backed start time on Windows when running there', async () => {
const observed = await readProcessStartTimeMs(process.pid, 'win32')
expect(observed === null).toBe(process.platform !== 'win32')
})
it('reads a start time for the current process on this platform', async () => {
const observed = await readProcessStartTimeMs(process.pid)
if (process.platform === 'linux' || process.platform === 'darwin') {
if (
process.platform === 'linux' ||
process.platform === 'darwin' ||
process.platform === 'win32'
) {
expect(observed).not.toBeNull()
expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan(
60_000
@@ -74,9 +74,26 @@ async function readDarwinProcessStartTimeMs(pid: number): Promise<number | null>
}
}
async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null> {
try {
const script =
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }'
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: PROCESS_START_TIME_TIMEOUT_MS, windowsHide: true }
)
const parsed = Date.parse(stdout.trim())
return Number.isFinite(parsed) ? parsed : null
} catch {
return null
}
}
/**
* Windows returns null: reading a start time needs a CIM query Orca does not make here, so a
* Windows child without a token echo qualifies for manual recovery only.
* Process start time is the cross-platform PID-reuse guard when no provider hook can echo the
* spawn token back to the owner probe.
*/
export async function readProcessStartTimeMs(
pid: number,
@@ -88,6 +105,9 @@ export async function readProcessStartTimeMs(
if (platform === 'darwin') {
return readDarwinProcessStartTimeMs(pid)
}
if (platform === 'win32') {
return readWindowsProcessStartTimeMs(pid)
}
return null
}
@@ -91,6 +91,49 @@ describe('admission through the store', () => {
})
})
it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)(
'refuses TUI writes while handoff stage %s is active',
(handoffStage) => {
publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage }))
const admission = gate.admit(PTY_ID)
expect(admission.admitted).toBe(false)
if (!admission.admitted) {
expect(admission.refusal).toMatchObject({
code: 'agent_session_conflict',
handoffStage,
ownerRuntimeKind: 'tui'
})
}
}
)
it('admits only the reserved TUI proof token before ownership', () => {
publish(
agentSessionLeaseFixture({
runtimeKind: 'tui',
claimStatus: 'reserved',
handoffStage: 'new-owner-proving',
ownerProcess: null,
reservedSpawnToken: 'proof-token'
})
)
expect(gate.admit(PTY_ID).admitted).toBe(false)
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true)
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe(
false
)
expect(
gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' })
).toBe(false)
})
it('refuses proof input after ownership is live', () => {
publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' }))
expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(
false
)
})
it('refuses when the record vanished from the store', () => {
const admission = gate.admit(PTY_ID)
expect(admission.admitted).toBe(false)
@@ -69,6 +69,22 @@ export class AgentSessionPtyWriteGate {
return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId))
}
/** Narrow pre-ownership input for the reserved TUI's provider identity probe. */
admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean {
const binding = this.binding(ptyId)
const lease = binding?.record?.lease
return Boolean(
binding?.sessionId === authority.sessionId &&
binding.record?.sessionId === authority.sessionId &&
lease?.runtimeKind === 'tui' &&
lease.claimStatus === 'reserved' &&
lease.handoffStage === 'new-owner-proving' &&
lease.ownerProcess === null &&
lease.reservedSpawnToken === authority.spawnToken &&
!lease.unreconciled
)
}
/** Re-check a write already in flight against the fence it was admitted under. */
readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission {
if (admitted.sessionId === null && !this.enforcing) {
@@ -0,0 +1,9 @@
import { isDeepStrictEqual } from 'node:util'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
export function agentSessionReconciliationTargetMatches(
current: AgentSessionRecord,
probed: AgentSessionRecord
): boolean {
return isDeepStrictEqual(current, probed)
}
@@ -9,6 +9,7 @@ import type {
AgentSessionRecord
} from '../../shared/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions'
import {
AGENT_SESSION_CLAIM_KEY_RETENTION_MS,
AgentSessionRecordStore
@@ -754,7 +755,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => {
it('rejects a handoff stage change under a different operation id', async () => {
const store = await open()
await establishOwner(store)
await store.setHandoffStage({
await setStoredAgentSessionHandoffStage(store, {
sessionId: 'session-alpha',
fence: 1,
stage: 'preparing',
@@ -762,7 +763,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => {
now: NOW
})
await expect(
store.setHandoffStage({
setStoredAgentSessionHandoffStage(store, {
sessionId: 'session-alpha',
fence: 1,
stage: 'old-owner-stopped',
+10 -18
View File
@@ -10,8 +10,9 @@
*/
import {
pruneAgentSessionOperationRows,
agentSessionOperationKey,
pruneAgentSessionOperationRows,
settleAgentSessionOperation,
type AgentSessionOperationDecision,
type AgentSessionOperationOutcome,
type AgentSessionOperationRow
@@ -25,22 +26,20 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-
import {
agentSessionScopeKey,
type AgentSessionExecutionLocation,
type AgentSessionHandoffStage,
type AgentSessionJournalCheckpoint,
type AgentSessionRecord
} from '../../shared/agent-session-record'
import {
applyAgentSessionRestartAdjudication,
agentSessionReconciliationTargetMatches,
commitAgentSessionProcessIdentity,
evictAgentSessionOwner,
proveAgentSessionOwner,
renewAgentSessionLease,
setAgentSessionHandoffStage,
setAgentSessionJournalCheckpoint,
type AgentSessionProcessIdentityCommit
} from './agent-session-lease-transitions'
import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition'
import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target'
import {
setAgentSessionReservationProcesslessProof,
type AgentSessionReservationProcesslessProof
@@ -238,14 +237,11 @@ export class AgentSessionRecordStore {
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
}
async setHandoffStage(args: {
sessionId: string
fence: number
stage: AgentSessionHandoffStage | null
handoffOperationId: string | null
now: number
}): Promise<AgentSessionRecord> {
return this.mutate(args.sessionId, (record) => setAgentSessionHandoffStage({ ...args, record }))
async transitionHandoff(
sessionId: string,
transition: (record: AgentSessionRecord) => AgentSessionRecord
): Promise<AgentSessionRecord> {
return this.mutate(sessionId, transition)
}
async setJournalCheckpoint(args: {
@@ -304,16 +300,12 @@ export class AgentSessionRecordStore {
}
async recordOperationOutcome(args: {
callerKey: string
callerKey?: string
operationId: string
outcome: AgentSessionOperationOutcome
}): Promise<void> {
await this.transact(() => {
const key = agentSessionOperationKey(args.callerKey, args.operationId)
const row = this.state.operations.get(key)
if (row) {
this.state.operations.set(key, { ...row, outcome: args.outcome })
}
this.state.operations = settleAgentSessionOperation(this.state.operations, args)
})
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication'
const NOW = 1_800_000_000_000
function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord {
return {
schemaVersion: 1,
sessionId: 'session-restart',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
providerHandleChain: [],
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' },
lease: {
sessionId: 'session-restart',
runtimeKind: stage === 'preparing' ? 'native' : 'tui',
runtimeFence: 4,
handoffStage: stage,
provenHandleLinkId: null,
ownerProcess: {
hostId: 'local',
pid: 4242,
processStartTimeMs: NOW - 1_000,
spawnToken: 'spawn-restart'
},
reservedSpawnToken: 'spawn-restart',
leaseDeadlineAt: NOW + 30_000,
lastRenewedAt: NOW,
handoffOperationId: 'handoff-op-1',
journalCheckpoint: null,
claimKeyId: 'key-1',
claimStatus: stage === 'preparing' ? 'live' : 'reserved',
unreconciled: true,
deathEvidence: null
},
createdAt: NOW,
updatedAt: NOW
}
}
describe('restarted handoff adjudication', () => {
it('continues a dead preparing owner at old-owner-stopped under the same operation', () => {
expect(
adjudicateRestartedAgentSessionHandoff(
record('preparing'),
{ outcome: 'pid-absent' },
NOW + 1_000
).lease
).toMatchObject({
runtimeKind: 'native',
runtimeFence: 5,
handoffStage: 'old-owner-stopped',
handoffOperationId: 'handoff-op-1',
claimStatus: 'released',
ownerProcess: null
})
})
it('rolls a dead proving target back to the source kind before retry', () => {
expect(
adjudicateRestartedAgentSessionHandoff(
record('new-owner-proving'),
{ outcome: 'pid-absent' },
NOW + 1_000
).lease
).toMatchObject({
runtimeKind: 'native',
runtimeFence: 5,
handoffStage: 'old-owner-stopped',
handoffOperationId: 'handoff-op-1'
})
})
})
@@ -0,0 +1,52 @@
import {
adjudicateAgentSessionRestart,
type AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
export function adjudicateRestartedAgentSessionHandoff(
record: AgentSessionRecord,
probe: AgentSessionOwnerProbe,
now: number
): AgentSessionRecord {
const adjudication = adjudicateAgentSessionRestart({
lease: record.lease,
probe,
observedAt: now
})
if (adjudication.disposition === 'readopt') {
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
}
if (adjudication.disposition !== 'evicted') {
return updateLease(record, {
...record.lease,
handoffStage: 'recovering',
claimStatus:
adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus,
unreconciled: false,
lastRenewedAt: now
})
}
const provingTarget = record.lease.handoffStage === 'new-owner-proving'
return updateLease(record, {
...record.lease,
runtimeKind: provingTarget
? record.lease.runtimeKind === 'native'
? 'tui'
: 'native'
: record.lease.runtimeKind,
runtimeFence: adjudication.nextFence,
handoffStage: 'old-owner-stopped',
ownerProcess: null,
reservedSpawnToken: null,
processlessAt: null,
claimStatus: 'released',
unreconciled: false,
lastRenewedAt: now,
deathEvidence: adjudication.evidence
})
}
function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) {
return { ...record, lease, updatedAt: lease.lastRenewedAt }
}
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,9 +13,16 @@ import type {
openClaudeStreamJsonConnection
} from '../claude/claude-stream-json-connection'
import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution'
import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity'
import {
CLAUDE_SPAWN_TOKEN_ENV,
claudeProviderHandleLink
} from '../claude/claude-structured-owner-identity'
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type {
StructuredAgentSessionHandoffTransport,
StructuredTuiOwner
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import type { OrcaRuntimeService } from './orca-runtime'
import type { RpcRequest, RpcResponse } from './rpc/core'
import { RpcDispatcher } from './rpc/dispatcher'
@@ -142,7 +149,7 @@ function ensureParams(fence: number) {
},
provider: 'claude' as const,
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/accounts/claude' },
accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: join(root, 'claude-home') },
runtimeKind: 'native' as const,
providerHandle: {
kind: 'claude' as const,
@@ -169,10 +176,20 @@ function ensureParams(fence: number) {
}
}
function handoffParams(direction: 'to-native' | 'to-tui', fence: number) {
const fields = { direction, mode: 'now' as const, action: 'start' as const }
return {
envelope: envelope('agentSession.requestHandoff', fields, fence),
...fields
}
}
let claude: ReturnType<typeof fakeClaude>
let root: string
let dispatcher: RpcDispatcher
let cleanups: Map<string, () => void>
let tuiOwner: StructuredTuiOwner | null
let transcriptPath: string
async function call(method: string, params: unknown): Promise<RpcResponse> {
const replies: RpcResponse[] = []
@@ -237,8 +254,63 @@ function textOf(item: AgentJournalRenderItem): string {
beforeEach(async () => {
operations = 0
root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-'))
transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`)
await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true })
claude = fakeClaude()
tuiOwner = null
cleanups = new Map()
const handoffTransport: StructuredAgentSessionHandoffTransport = {
hostLabel: 'Scripted Claude host',
launchTui: async ({ record, fence, spawnToken }) => {
const head = record.providerHandleChain.at(-1)?.handle
tuiOwner = {
terminal: {
handle: 'term-claude-tui',
tabId: 'tab-claude-tui',
paneKey: 'tab-claude-tui:leaf-claude-tui',
ptyId: 'pty-claude-tui'
},
process: {
hostId: 'local',
pid: 7331,
processStartTimeMs: 100,
spawnToken
},
link: claudeProviderHandleLink({
sessionId: PROVIDER_SESSION,
leafUuid: head?.provider === 'claude' ? head.leafUuid : null,
resumed: true,
fence,
observedAt: Date.now()
}),
transcriptPath
}
return tuiOwner
},
reproveTuiOwner: async ({ owner }) => owner,
recoverTuiOwner: async () => {
if (!tuiOwner) {
throw new Error('scripted TUI owner missing')
}
return tuiOwner
},
stopRecoveredOwner: async () => {},
waitForTuiExit: async (owner, persistHandle) => {
await persistHandle(
claudeProviderHandleLink({
sessionId: PROVIDER_SESSION,
leafUuid: 'tui-assistant',
resumed: true,
fence: owner.link.mintedAtFence,
observedAt: Date.now()
})
)
return { transcriptPath }
},
waitForTuiIdleOrExit: async () => 'idle',
tuiStatus: () => 'idle',
stopFailedTuiLaunch: async () => {}
}
const runtime = {
getRuntimeId: () => 'runtime-1',
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
@@ -260,7 +332,8 @@ beforeEach(async () => {
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test'
}),
openClaudeConnection: claude.openConnection
openClaudeConnection: claude.openConnection,
handoffTransport
}).then(() => undefined),
registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose),
cleanupSubscription: (id: string) => cleanups.get(id)?.(),
@@ -306,7 +379,7 @@ describe('a structured Claude session over agentSession.*', () => {
expect(claude.live().launch.env).toEqual({
ANTHROPIC_AUTH_TOKEN: 'configured-token',
ANTHROPIC_BASE_URL: 'https://gateway.example.test',
CLAUDE_CONFIG_DIR: '/accounts/claude',
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
})
const stream = await subscribe()
@@ -398,4 +471,77 @@ describe('a structured Claude session over agentSession.*', () => {
origin: 'resumed'
})
})
it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => {
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
await writeFile(
transcriptPath,
[
{
type: 'user',
uuid: 'native-user',
message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] }
},
{
type: 'assistant',
uuid: 'native-assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] }
},
{
type: 'user',
uuid: 'tui-user',
message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] }
},
{
type: 'assistant',
uuid: 'tui-assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] }
},
{ type: 'last-prompt', leafUuid: 'tui-assistant' }
]
.map((entry) => JSON.stringify(entry))
.join('\n')
)
await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence))
const host = getStructuredAgentSessionHost()!
await vi.waitFor(async () =>
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' })
)
expect(claude.connections[0]?.closed).toBe(true)
const tuiFence = (
host as unknown as {
deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } }
}
).deps.store.getRecord(SESSION).lease.runtimeFence
await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence))
await vi.waitFor(async () =>
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' })
)
const frames = await subscribe()
const texts = itemsOf(frames).map(textOf).filter(Boolean)
expect(texts).toEqual(
expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT'])
)
expect(new Set(texts).size).toBe(texts.length)
expect(claude.connections).toHaveLength(2)
expect(claude.live().launch.args.slice(-2)).toEqual(['--resume', PROVIDER_SESSION])
const record = (
host as unknown as {
deps: {
store: {
getRecord: (id: string) => {
providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[]
}
}
}
}
).deps.store.getRecord(SESSION)
expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({
provider: 'claude',
leafUuid: 'tui-assistant'
})
})
})
@@ -0,0 +1,220 @@
import { describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import { OrcaRuntimeService } from './orca-runtime'
const { probeAgentSessionProcessIdentity, readStructuredTuiProcessIdentity } = vi.hoisted(() => ({
probeAgentSessionProcessIdentity: vi.fn(),
readStructuredTuiProcessIdentity: vi.fn()
}))
vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity }))
vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({
...(await importOriginal()),
probeAgentSessionProcessIdentity
}))
const WORKTREE_ID = 'repo-1::/tmp/structured-handoff'
function notifier(revealTerminalSession: ReturnType<typeof vi.fn>) {
return {
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
}
}
describe('structured TUI launch tab binding', () => {
it('proves the published launch tab before returning its revealed renderer binding', async () => {
let explicitStatus: {
state: 'working' | 'done'
prompt: string
receivedAt: number
stateStartedAt: number
paneKey: string
terminalHandle: string
} | null = null
const revealTerminalSession = vi.fn(
(_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) =>
Promise.resolve({ tabId: 'tab-renderer' })
)
const runtime = new OrcaRuntimeService(
{
getSettings: () => ({
disabledTuiAgents: [],
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {}
})
} as never,
undefined,
{
getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : [])
}
)
runtime.setNotifier(notifier(revealTerminalSession) as never)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const internal = runtime as unknown as {
createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport
resolveTerminalWorkspaceLaunchScope(): Promise<{
id: string
path: string
connectionId: null
repo: null
folderWorkspace: null
}>
markLocalWorkspaceTrustedForAgent(): void
waitForTerminal(): Promise<unknown>
waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }>
waitForStructuredTuiPtyExit(): Promise<void>
handles: Map<
string,
{
rendererGraphEpoch: number
tabId: string
leafId: string
}
>
graphStatus: 'ready'
}
internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({
id: WORKTREE_ID,
path: '/tmp/structured-handoff',
connectionId: null,
repo: null,
folderWorkspace: null
}))
internal.markLocalWorkspaceTrustedForAgent = vi.fn()
const waitForTerminal = vi.fn(async () => ({}))
internal.waitForTerminal = waitForTerminal
const waitForStructuredTuiProof = vi.fn(async () => {
const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toContainEqual(
expect.objectContaining({
type: 'terminal',
parentTabId: expect.any(String),
leafId: expect.any(String),
ptyId: 'pty-structured',
terminal: expect.any(String)
})
)
expect(revealTerminalSession).not.toHaveBeenCalled()
return { transcriptPath: '/tmp/rollout.jsonl' }
})
internal.waitForStructuredTuiProof = waitForStructuredTuiProof
const waitForStructuredTuiPtyExit = vi.fn(async () => {})
internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit
readStructuredTuiProcessIdentity.mockResolvedValue({
hostId: 'local',
pid: 4243,
processStartTimeMs: 10,
spawnToken: 'spawn-token'
})
probeAgentSessionProcessIdentity.mockResolvedValue({
outcome: 'identity-matched',
matchedOn: ['process-start-time']
})
const transport = internal.createStructuredAgentSessionHandoffTransport()
const owner = await transport.launchTui({
record: {
sessionId: 'session-1',
location: { workspaceId: WORKTREE_ID, executionHostId: 'local' },
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
providerHandleChain: [
{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }
]
} as never,
fence: 3,
spawnToken: 'spawn-token'
})
const reveal = revealTerminalSession.mock.calls[0]?.[1] as {
tabId: string
leafId: string
}
expect(owner.terminal).toMatchObject({
tabId: 'tab-renderer',
paneKey: `${reveal.tabId}:${reveal.leafId}`,
ptyId: 'pty-structured'
})
expect(waitForTerminal).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ condition: 'tui-idle' })
)
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
revealTerminalSession.mock.invocationCallOrder[0]!
)
Object.assign(internal.handles.get(owner.terminal.handle)!, {
rendererGraphEpoch: -1,
tabId: 'tab-retired',
leafId: 'leaf-retired'
})
internal.graphStatus = 'ready'
explicitStatus = {
state: 'working',
prompt: '',
receivedAt: Date.now(),
stateStartedAt: Date.now(),
paneKey: owner.terminal.paneKey,
terminalHandle: owner.terminal.handle
}
expect(transport.tuiStatus(owner)).toBe('busy')
await expect(
transport.waitForTuiIdleOrExit(owner, new AbortController().signal)
).resolves.toBeNull()
explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() }
expect(transport.tuiStatus(owner)).toBe('idle')
await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe(
'idle'
)
const pty = (
runtime as unknown as {
ptysById: Map<string, { connected: boolean; launchToken: string | null }>
}
).ptysById.get('pty-structured')!
pty.launchToken = null
const persistedRecord = {
sessionId: 'session-1',
providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }],
lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId }
} as never
const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner })
expect(rebound.terminal).toMatchObject({
ptyId: 'pty-structured',
tabId: owner.terminal.tabId,
paneKey: owner.terminal.paneKey
})
expect(rebound.terminal.handle).not.toBe(owner.terminal.handle)
await transport.waitForTuiExit(rebound, async () => {})
expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured')
expect(waitForStructuredTuiProof).toHaveBeenCalledOnce()
explicitStatus = null
pty.connected = false
await expect(
transport.waitForTuiIdleOrExit(rebound, new AbortController().signal)
).resolves.toBe('exited')
await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined()
})
})
+540 -21
View File
@@ -74,6 +74,30 @@ import {
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import type {
StructuredAgentSessionHandoffTransport,
StructuredTuiOwner
} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionProviderHandlesEqual } from '../../shared/agent-session-provider-handle'
import { completeClaudeTuiExit } from '../claude/claude-tui-exit'
import {
createClaudeTuiResumeLaunchBuilder,
type ClaudeTuiResumeLaunch
} from '../claude/claude-tui-resume-launch'
import {
proveClaudeTuiResume,
readClaudeTuiSessionStartEvidence
} from '../claude/claude-tui-resume-proof'
import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity'
import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity'
import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { agentHookServer } from '../agent-hooks/server'
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof'
import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity'
import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import {
@@ -119,7 +143,7 @@ import {
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance'
import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { homedir, hostname } from 'node:os'
import { isAbsolute, join, resolve } from 'node:path'
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
@@ -427,7 +451,10 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import {
buildAgentDraftLaunchPlan,
buildAgentResumeStartupPlan,
buildAgentStartupPlan
buildAgentStartupPlan,
buildShellCommandFromArgv,
quoteStartupArg,
resolveStartupShell
} from '../../shared/tui-agent-startup'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import {
@@ -1390,6 +1417,7 @@ type TerminalCreateOptions = {
// Why: only the host-derived structured resume path may attach provider
// identity; opaque terminal.create commands remain ordinary shells.
agentSessionClaim?: AgentSessionExecutionClaim
structuredAgentSessionId?: string
agentSessionCreateOperationId?: string
signal?: AbortSignal
// Why: idempotent create operations must retain their fence after the PTY
@@ -1402,6 +1430,13 @@ type TerminalCreateOptions = {
deferMobileSessionPublish?: boolean
}
type StructuredAgentSessionHandoffAuthority = {
spawnToken: string
providerHome: string
sessionId: string
launch?: ClaudeTuiResumeLaunch
}
function mergeTerminalEnvDeletionKeys(
first: readonly string[] | undefined,
second: readonly string[] | undefined
@@ -1705,12 +1740,18 @@ type RuntimePtyController = {
}
}): Promise<{
id: string
pid?: number
incarnationId?: PtyIncarnationId
wslDistro?: string
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}>
write(ptyId: string, data: string): boolean
writeAgentSessionProof?(
ptyId: string,
data: string,
authority: { sessionId: string; spawnToken: string }
): boolean
/** Attach-only adoption of a live local daemon session so its output streams
* to main without a renderer pane; never creates, resizes, or focuses.
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
@@ -9088,6 +9129,442 @@ export class OrcaRuntimeService {
resolveClaudeLaunchEnv: () => {
const settings = this.requireStore().getSettings()
return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv)
},
handoffTransport: this.createStructuredAgentSessionHandoffTransport()
})
}
private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport {
return {
hostLabel: hostname(),
launchTui: async ({ record, fence, spawnToken }) => {
const head = record.providerHandleChain.at(-1)
if (!head) {
throw new Error('agent_session_identity_required')
}
const agent = head.handle.provider
const providerSessionId =
head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
let claudeProof:
| { prove: () => ReturnType<typeof proveClaudeTuiResume>; dispose: () => void }
| undefined
let claudeLaunch: ClaudeTuiResumeLaunch | undefined
if (head.handle.provider === 'claude') {
const expectedTranscriptPath = await resolveSessionFilePath('claude', providerSessionId, {
claudeProjectsDir: join(record.accountHome.path, 'projects')
})
if (!expectedTranscriptPath) {
throw new Error('The Claude transcript could not be resolved before TUI resume.')
}
claudeLaunch = await createClaudeTuiResumeLaunchBuilder({
resolveWorkspacePath: async (workspaceId) =>
(await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path,
resolveEnv: () => {
const settings = this.requireStore().getSettings()
return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv)
}
})({ record, spawnToken })
claudeProof = this.createClaudeTuiResumeProofWaiter({
expectedSessionId: providerSessionId,
expectedTranscriptPath,
expectedLaunchToken: spawnToken
})
}
let launched: RuntimeEnsureAgentSessionResult
try {
launched = await this.ensureAgentSession(
{
kind: 'explicit',
worktree: `id:${record.location.workspaceId}`,
agent,
providerSession: { key: 'session_id', id: providerSessionId },
presentation: 'background'
},
{},
{
spawnToken,
providerHome: record.accountHome.path,
sessionId: record.sessionId,
...(claudeLaunch ? { launch: claudeLaunch } : {})
}
)
} catch (error) {
claudeProof?.dispose()
throw error
}
const terminal = launched.terminal
try {
if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) {
throw new Error('The resumed terminal did not publish a process identity.')
}
await this.waitForTerminal(terminal.handle, {
condition: 'tui-idle',
timeoutMs: 30_000
})
const proof =
head.handle.provider === 'codex'
? await this.waitForStructuredTuiProof({
handle: terminal.handle,
paneKey: terminal.paneKey,
threadId: head.handle.threadId,
spawnToken,
codexHome: record.accountHome.path,
sessionId: record.sessionId
})
: await claudeProof!.prove()
const revealed = await this.focusTerminal(terminal.handle)
return this.refreshStructuredTuiOwnerBinding({
terminal: {
handle: terminal.handle,
tabId: revealed.tabId,
paneKey: terminal.paneKey,
ptyId: terminal.ptyId
},
process: await readStructuredTuiProcessIdentity({
hostId: record.location.executionHostId,
rootPid: terminal.processId,
spawnToken,
agent
}),
link:
head.handle.provider === 'codex'
? codexProviderHandleLink({
threadId: head.handle.threadId,
resumed: true,
fence,
observedAt: Date.now()
})
: claudeProviderHandleLink({
sessionId: head.handle.sessionId,
leafUuid: head.handle.leafUuid,
resumed: true,
fence,
observedAt: Date.now()
}),
...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {})
})
} catch (error) {
await this.closeTerminal(terminal.handle)
await this.waitForTerminal(terminal.handle, {
condition: 'exit',
timeoutMs: 15_000
})
throw error
} finally {
claudeProof?.dispose()
}
},
waitForTuiExit: async (owner, persistHandle) => {
if (owner.link.handle.provider === 'claude') {
if (!owner.transcriptPath) {
throw new Error('The Claude TUI owner did not retain its transcript path.')
}
const completed = await completeClaudeTuiExit({
childPid: owner.process.pid,
waitForChildExit: async () => {
await this.waitForStructuredTuiOwnerExit(owner)
return { pid: owner.process.pid, exitCode: null, signal: null }
},
sessionId: owner.link.handle.sessionId,
transcriptPath: owner.transcriptPath,
fence: owner.link.mintedAtFence,
persistHandle
})
return { transcriptPath: completed.transcriptPath }
}
await this.waitForStructuredTuiOwnerExit(owner)
return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {}
},
waitForTuiIdleOrExit: async (owner, signal) => {
return this.waitForStructuredTuiIdleOrExit(owner, signal)
},
reproveTuiOwner: async ({ record, owner }) => {
const current = this.refreshStructuredTuiOwnerBinding(owner)
const persisted = record.lease.ownerProcess
if (
!persisted ||
persisted.hostId !== current.process.hostId ||
persisted.pid !== current.process.pid ||
persisted.processStartTimeMs !== current.process.processStartTimeMs ||
persisted.spawnToken !== current.process.spawnToken
) {
throw new Error('The owning terminal does not match the persisted launch identity.')
}
const proof = await probeAgentSessionProcessIdentity({ identity: current.process })
if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) {
throw new Error('The owning agent child process could not be re-proved.')
}
const head = record.providerHandleChain.at(-1)
if (
!head ||
(record.lease.provenHandleLinkId !== null &&
current.link.linkId !== record.lease.provenHandleLinkId) ||
!agentSessionProviderHandlesEqual(current.link.handle, head.handle)
) {
throw new Error('agent_session_identity_required')
}
return current
},
recoverTuiOwner: async (record) => {
const identity = record.lease.ownerProcess
const head = record.providerHandleChain.at(-1)
if (!identity || !head) {
throw new Error('agent_session_identity_required')
}
const candidate = [...this.ptysById.values()].find(
(pty) =>
pty.connected &&
pty.launchToken === identity.spawnToken &&
pty.launchAgent === head.handle.provider &&
pty.tabId &&
pty.paneKey
)
const handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null
if (!candidate?.tabId || !candidate.paneKey || !handle) {
throw new Error('The owning agent terminal could not be recovered.')
}
const transcriptPath =
head.handle.provider === 'codex'
? (
await this.waitForStructuredTuiProof({
handle,
paneKey: candidate.paneKey,
threadId: head.handle.threadId,
spawnToken: identity.spawnToken,
codexHome: record.accountHome.path,
sessionId: record.sessionId
})
).transcriptPath
: await resolveSessionFilePath('claude', head.handle.sessionId, {
claudeProjectsDir: join(record.accountHome.path, 'projects')
})
if (head.handle.provider === 'claude' && !transcriptPath) {
throw new Error('The owning Claude transcript could not be recovered.')
}
return {
terminal: {
handle,
tabId: candidate.tabId,
paneKey: candidate.paneKey,
ptyId: candidate.ptyId
},
process: identity,
link:
head.handle.provider === 'codex'
? codexProviderHandleLink({
threadId: head.handle.threadId,
resumed: true,
fence: record.lease.runtimeFence,
observedAt: Date.now()
})
: head,
...(transcriptPath ? { transcriptPath } : {})
}
},
stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record),
tuiStatus: (owner) => this.structuredTuiStatus(owner),
stopFailedTuiLaunch: async (owner) => {
if (this.ptysById.get(owner.terminal.ptyId)?.connected) {
const current = this.refreshStructuredTuiOwnerBinding(owner)
try {
await this.closeTerminal(current.terminal.handle)
} catch (error) {
if (this.ptysById.get(owner.terminal.ptyId)?.connected) {
throw error
}
}
}
await this.waitForStructuredTuiOwnerExit(owner)
}
}
}
private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner {
const pty = this.ptysById.get(owner.terminal.ptyId)
if (!pty?.connected) {
throw new Error('The owning agent terminal lost its launch identity.')
}
const handle = this.issueStructuredTuiPtyHandle(pty)
if (handle === owner.terminal.handle) {
return owner
}
return { ...owner, terminal: { ...owner.terminal, handle } }
}
private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string {
const existingHandle = this.findHandleForPtyRecord(pty.ptyId)
if (existingHandle) {
this.handleByPtyId.set(pty.ptyId, existingHandle)
return existingHandle
}
const handle = `term_${randomUUID()}`
const syntheticId = `pty:${pty.ptyId}`
this.syntheticTerminalHandles.add(handle)
this.handles.set(handle, {
handle,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: pty.worktreeId,
tabId: syntheticId,
leafId: syntheticId,
ptyId: pty.ptyId,
ptyGeneration: 0
})
this.handleByPtyId.set(pty.ptyId, handle)
return handle
}
private async waitForStructuredTuiPtyExit(ptyId: string): Promise<void> {
while (this.ptysById.get(ptyId)?.connected === true) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise<void> {
await waitForStructuredTuiExitProof({
identity: owner.process,
waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId)
})
}
private async waitForStructuredTuiIdleOrExit(
owner: StructuredTuiOwner,
signal: AbortSignal
): Promise<'idle' | 'exited' | null> {
const deadline = Date.now() + 250
while (!signal.aborted && Date.now() < deadline) {
if (!this.ptysById.get(owner.terminal.ptyId)?.connected) {
await this.waitForStructuredTuiOwnerExit(owner)
return 'exited'
}
if (this.structuredTuiStatus(owner) === 'idle') {
return 'idle'
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return null
}
private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise<void> {
const identity = record.lease.ownerProcess
if (!identity) {
return
}
const proof = await probeAgentSessionProcessIdentity({ identity })
if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') {
return
}
if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) {
throw new Error('The recovered owner process could not be stopped safely.')
}
try {
process.kill(identity.pid, 'SIGTERM')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
throw error
}
return
}
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
const current = await probeAgentSessionProcessIdentity({ identity })
if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') {
return
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error('The recovered owner process did not exit.')
}
private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' {
const pty = this.ptysById.get(owner.terminal.ptyId)
const paneKey = pty?.paneKey ?? owner.terminal.paneKey
const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey)
if (explicit) {
return explicit.status === 'idle' ? 'idle' : 'busy'
}
if (pty?.connected) {
const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
return hasStructuredTuiIdleEvidence({
blocked: detectTerminalWaitBlockedReason(text) !== null,
status: pty.lastAgentStatus,
statusObservedLive: pty.lastAgentStatusObservedLive
})
? 'idle'
: 'busy'
}
return 'busy'
}
private createClaudeTuiResumeProofWaiter(input: {
expectedSessionId: string
expectedTranscriptPath: string
expectedLaunchToken: string
}) {
let resolveEnvelope!: (envelope: unknown) => void
const envelope = new Promise<unknown>((resolve) => {
resolveEnvelope = resolve
})
const dispose = agentHookServer.subscribeClaudeHookEnvelopes((candidate) => {
const evidence = readClaudeTuiSessionStartEvidence(candidate)
if (
evidence?.launchToken === input.expectedLaunchToken &&
evidence.sessionId === input.expectedSessionId
) {
resolveEnvelope(candidate)
}
})
return {
prove: () =>
proveClaudeTuiResume({
...input,
waitForSessionStart: () => envelope
}),
dispose
}
}
private async waitForStructuredTuiProof(input: {
handle: string
paneKey: string
threadId: string
spawnToken: string
codexHome: string
sessionId: string
}): Promise<{ transcriptPath?: string }> {
const readBoundPty = (): RuntimePtyWorktreeRecord => {
const pty = this.getLivePtyForHandle(input.handle)?.pty
if (
!pty?.connected ||
pty.paneKey !== input.paneKey ||
pty.launchAgent !== 'codex' ||
pty.launchToken !== input.spawnToken
) {
throw new Error('The resumed terminal lost its launch identity.')
}
return pty
}
const initialPty = readBoundPty()
const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0
return proveCodexTuiRollout({
codexHome: input.codexHome,
threadId: input.threadId,
kittyKeyboardFlags,
readOutput: () => {
const pty = readBoundPty()
return {
text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview),
lastOutputAt: pty.lastOutputAt
}
},
write: (data) => {
const pty = readBoundPty()
return (
this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, {
sessionId: input.sessionId,
spawnToken: input.spawnToken
}) ?? false
)
}
})
}
@@ -13820,6 +14297,7 @@ export class OrcaRuntimeService {
this.intentionalHandlelessPtyStops.has(ptyId) &&
(intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId)
advertisedUrlWatcher.unbindPty(ptyId)
agentSessionPtyWriteGate.unbindPty(ptyId)
// Clean up new mobile state for this PTY
this.mobileSubscribers.delete(ptyId)
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
@@ -17347,11 +17825,14 @@ export class OrcaRuntimeService {
return true
}
private getFreshExplicitAgentStatusForHandle(handle: string): {
private getFreshExplicitAgentStatusForHandle(
handle: string,
paneKeyOverride?: string | null
): {
status: NonNullable<RuntimeTerminalAgentStatus['status']>
updatedAt: number
} | null {
const paneKey = this.getPaneKeyForTerminalHandle(handle)
const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle)
const now = Date.now()
let bestStatus: NonNullable<RuntimeTerminalAgentStatus['status']> | null = null
let bestUpdatedAt = -1
@@ -25346,7 +25827,8 @@ export class OrcaRuntimeService {
async ensureAgentSession(
request: RuntimeEnsureAgentSessionRequest,
_caller: RuntimeAgentSessionRpcCaller = {}
_caller: RuntimeAgentSessionRpcCaller = {},
handoffAuthority?: StructuredAgentSessionHandoffAuthority
): Promise<RuntimeEnsureAgentSessionResult> {
if (request.kind === 'automatic') {
// Legacy renderer sleep records are migration evidence, not host authority.
@@ -25356,7 +25838,11 @@ export class OrcaRuntimeService {
throw new Error('runtime_unavailable')
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent)
const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent)
const namespace =
resolvedNamespace && handoffAuthority
? { ...resolvedNamespace, providerRoot: handoffAuthority.providerHome }
: resolvedNamespace
if (
!namespace ||
!(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal))
@@ -25382,21 +25868,43 @@ export class OrcaRuntimeService {
isRemote,
terminalWindowsShell: settings.terminalWindowsShell
})
const startup = buildAgentResumeStartupPlan({
agent: request.agent,
providerSession: identity.providerSession,
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs:
request.agentArgs !== undefined
? request.agentArgs
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
ompResumeFilePath: request.ompResumeFilePath,
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
platform,
shell,
isRemote
})
const startupShell = resolveStartupShell(platform, shell)
const startup = handoffAuthority?.launch
? {
agent: request.agent,
launchCommand: buildShellCommandFromArgv(
[handoffAuthority.launch.command, ...handoffAuthority.launch.args],
startupShell
),
expectedProcess: TUI_AGENT_CONFIG[request.agent].expectedProcess,
followupPrompt: null,
launchConfig: {
agentCommand: handoffAuthority.launch.command,
agentArgs: handoffAuthority.launch.args
.map((arg) => quoteStartupArg(arg, startupShell))
.join(' '),
agentEnv: handoffAuthority.launch.env
},
env: handoffAuthority.launch.env
}
: buildAgentResumeStartupPlan({
agent: request.agent,
providerSession: identity.providerSession,
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs:
request.agentArgs !== undefined
? request.agentArgs
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
agentEnv: {
...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
...(handoffAuthority ? { CODEX_HOME: handoffAuthority.providerHome } : {})
},
ompResumeFilePath: request.ompResumeFilePath,
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
platform,
shell,
isRemote
})
if (!startup) {
throw new Error('agent_session_identity_required')
}
@@ -25414,6 +25922,7 @@ export class OrcaRuntimeService {
}
const terminal = await this.createTerminal(`id:${workspace.id}`, {
command: startup.launchCommand,
...(handoffAuthority?.launch ? { cwd: handoffAuthority.launch.cwd } : {}),
env: startup.env,
launchConfig: startup.launchConfig,
launchAgent: request.agent,
@@ -25422,6 +25931,12 @@ export class OrcaRuntimeService {
leafId: request.placement?.leafId,
persistHostSessionBinding: true,
agentSessionClaim: claim,
...(handoffAuthority
? {
launchToken: handoffAuthority.spawnToken,
structuredAgentSessionId: handoffAuthority.sessionId
}
: {}),
signal: _caller.signal
})
return {
@@ -25925,6 +26440,9 @@ export class OrcaRuntimeService {
leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (launchOpts.structuredAgentSessionId) {
agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId)
}
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (persistHostSessionBinding) {
@@ -26002,6 +26520,7 @@ export class OrcaRuntimeService {
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
@@ -191,3 +191,14 @@ export const UnsubscribeParams = z
subscriptionId: Identifier('Invalid subscription id').optional()
})
.strict()
export const HandoffParams = z
.object({
envelope: MutationEnvelope,
direction: z.enum(['to-tui', 'to-native']),
mode: z.enum(['now', 'after-turn', 'stop-turn']),
action: z.enum(['start', 'cancel-queued', 'retry']).optional()
})
.strict()
export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
@@ -94,6 +94,8 @@ function hostStub(): StructuredAgentSessionHost {
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({ ok: true, replayed: false })),
handoffStatus: vi.fn(async () => ({ owner: 'tui', phase: 'queued' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
@@ -189,7 +191,7 @@ describe('capability gating', () => {
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
expect(names).toContain(method.name)
}
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(12)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -313,6 +315,22 @@ describe('method routing', () => {
'question'
])
})
it('routes a mobile reverse request through the host-owned handoff coordinator', async () => {
const params = {
envelope: envelope(),
direction: 'to-native',
mode: 'after-turn',
action: 'start'
}
const response = await call('agentSession.requestHandoff', params, {
...STRUCTURED_CLIENT,
clientId: 'mobile-device-a'
})
expect(response).toMatchObject({ ok: true })
expect(hostCalls.requestHandoff).toHaveBeenCalledWith({ callerKey: 'mobile-device-a' }, params)
})
})
describe('mobile image provenance', () => {
@@ -12,10 +12,8 @@ import {
computeAgentSessionPayloadFingerprint
} from '../../../../shared/agent-session-mutation-envelope'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHost
} from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import {
AttachParams,
@@ -23,6 +21,8 @@ import {
CreateParams,
CreateSupportParams,
HistoryParams,
HandoffParams,
HandoffStatusParams,
OptionsParams,
RespondParams,
SendParams,
@@ -209,6 +209,16 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
params: SetOptionParams,
handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.requestHandoff',
params: HandoffParams,
handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.handoffStatus',
params: HandoffStatusParams,
handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId)
}),
defineMethod({
name: 'agentSession.options',
params: OptionsParams,
+2
View File
@@ -190,12 +190,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'agentSession.create',
'agentSession.createSupport',
'agentSession.cancel',
'agentSession.handoffStatus',
'agentSession.history',
'agentSession.options',
'agentSession.respondToApproval',
'agentSession.respondToQuestion',
'agentSession.send',
'agentSession.setOption',
'agentSession.requestHandoff',
'agentSession.subscribe',
'agentSession.unsubscribe',
'browser.back',
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const SESSION_ID = 'structured-session-1'
afterEach(() => agentSessionPtyWriteGate.detachRecordLookup())
describe('structured handoff PTY binding', () => {
it('binds before runtime writes and unbinds on process exit', async () => {
const runtime = new OrcaRuntimeService()
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{
id: string
path: string
connectionId: null
repo: null
folderWorkspace: null
}>
}
vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'worktree-1',
path: '/tmp/worktree-1',
connectionId: null,
repo: null,
folderWorkspace: null
})
const write = vi.fn(() => true)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })),
write,
kill: () => true,
getForegroundProcess: async () => null
})
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({
sessionId: SESSION_ID,
runtimeKind: 'native',
handoffStage: 'new-owner-proving'
})
)
agentSessionPtyWriteGate.attachRecordLookup((sessionId) =>
sessionId === SESSION_ID ? record : null
)
await runtime.createTerminal('id:worktree-1', {
command: 'codex resume thread-1',
structuredAgentSessionId: SESSION_ID
})
expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID)
await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false)
expect(write).not.toHaveBeenCalled()
runtime.onPtyExit('pty-structured', 0)
expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull()
})
})
@@ -23,9 +23,11 @@ import {
} from '../codex/codex-structured-session-adapter'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { AgentSessionRecordStore } from './agent-session-record-store'
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
/** Sibling of the journal tree rather than inside it: one file adjudicates every
* session's lease, while a journal is per session. */
@@ -48,6 +50,7 @@ export type StructuredAgentSessionRuntimeDeps = {
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
onError?: (input: { scope: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
type InstalledRuntime = {
@@ -74,6 +77,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise<void> {
const pending = installing
installing = null
setStructuredAgentSessionHost(null)
agentSessionPtyWriteGate.detachRecordLookup()
if (!pending) {
return
}
@@ -93,56 +97,63 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME),
hostId: deps.hostId
})
const codex = new CodexStructuredSessionAdapter({
resolveLaunch: createCodexStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
}),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {})
})
const claude = new ClaudeStructuredSessionAdapter({
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
}),
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
persistHandle: async (observed) => {
const record = store.getRecord(observed.sessionId)
if (!record || record.provider !== 'claude') {
return
}
const fence = record.lease.runtimeFence
await store.recordProviderHandle({
sessionId: observed.sessionId,
fence,
link: claudeProviderHandleLink({
sessionId: observed.providerSessionId,
leafUuid: observed.leafUuid,
resumed: record.providerHandleChain.length > 0,
agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId))
try {
const codex = new CodexStructuredSessionAdapter({
resolveLaunch: createCodexStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
}),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {})
})
const claude = new ClaudeStructuredSessionAdapter({
resolveLaunch: createClaudeStructuredLaunchResolver({
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}),
...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {})
}),
...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}),
persistHandle: async (observed) => {
const record = store.getRecord(observed.sessionId)
if (!record || record.provider !== 'claude') {
return
}
const fence = record.lease.runtimeFence
await store.recordProviderHandle({
sessionId: observed.sessionId,
fence,
observedAt: Date.now()
}),
now: Date.now()
})
}
})
const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () =>
Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined)
)
const host = new StructuredAgentSessionHost({
store,
adapter,
journalRoot: deps.stateDirectory,
claimKeyId: deps.claimKeyId,
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
onEventSinkError: ({ sessionId, error }) =>
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error })
})
setStructuredAgentSessionHost(host)
return { host, adapter }
link: claudeProviderHandleLink({
sessionId: observed.providerSessionId,
leafUuid: observed.leafUuid,
resumed: record.providerHandleChain.length > 0,
fence,
observedAt: Date.now()
}),
now: Date.now()
})
}
})
const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () =>
Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined)
)
const host = new StructuredAgentSessionHost({
store,
adapter,
journalRoot: deps.stateDirectory,
claimKeyId: deps.claimKeyId,
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
onEventSinkError: ({ sessionId, error }) =>
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }),
...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {})
})
setStructuredAgentSessionHost(host)
return { host, adapter }
} catch (error) {
agentSessionPtyWriteGate.detachRecordLookup()
throw error
}
}
/**
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof'
const identity: AgentSessionProcessIdentity = {
hostId: 'local',
pid: 123,
processStartTimeMs: 1_700_000_000_000,
spawnToken: 'spawn-token'
}
describe('structured TUI exit proof', () => {
it('accepts the exact terminal exit event without probing', async () => {
const probe = vi.fn()
await expect(
waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe })
).resolves.toBeUndefined()
expect(probe).not.toHaveBeenCalled()
})
it.each([
{ outcome: 'pid-absent' as const },
{ outcome: 'identity-mismatch' as const, field: 'process-start-time' as const }
])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => {
await expect(
waitForStructuredTuiExitProof({
identity,
waitForExit: async () => {
throw new Error('terminal_handle_stale')
},
probe: async () => proof
})
).resolves.toBeUndefined()
})
it.each([
{ outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] },
{ outcome: 'indeterminate' as const, reason: 'probe unavailable' }
])('fails closed while the recorded process may still own: $outcome', async (proof) => {
await expect(
waitForStructuredTuiExitProof({
identity,
waitForExit: async () => {
throw new Error('terminal_handle_stale')
},
probe: async () => proof,
staleHandleProbeAttempts: 1
})
).rejects.toThrow('terminal_handle_stale')
})
it('retries the persisted process identity when handle retirement wins the exit race', async () => {
const probe = vi
.fn()
.mockResolvedValueOnce({
outcome: 'identity-matched' as const,
matchedOn: ['process-start-time' as const]
})
.mockResolvedValueOnce({ outcome: 'pid-absent' as const })
await expect(
waitForStructuredTuiExitProof({
identity,
waitForExit: async () => {
throw new Error('terminal_handle_stale')
},
probe,
staleHandleProbeAttempts: 2,
staleHandleProbeIntervalMs: 0
})
).resolves.toBeUndefined()
expect(probe).toHaveBeenNthCalledWith(1, identity)
expect(probe).toHaveBeenNthCalledWith(2, identity)
})
it('preserves unrelated terminal wait failures', async () => {
const probe = vi.fn()
await expect(
waitForStructuredTuiExitProof({
identity,
waitForExit: async () => {
throw new Error('timeout')
},
probe
})
).rejects.toThrow('timeout')
expect(probe).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,44 @@
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
type ExitProofInput = {
identity: AgentSessionProcessIdentity
waitForExit: () => Promise<unknown>
probe?: (identity: AgentSessionProcessIdentity) => Promise<AgentSessionOwnerProbe>
staleHandleProbeAttempts?: number
staleHandleProbeIntervalMs?: number
}
const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50
const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100
function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean {
return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch'
}
async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise<void> {
const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity }))
const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS
const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (provesRecordedProcessExited(await probe(input.identity))) {
return
}
if (attempt + 1 < attempts) {
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
}
throw staleError
}
export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise<void> {
try {
await input.waitForExit()
} catch (error) {
if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') {
throw error
}
await waitForRecordedProcessExit(input, error)
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence'
describe('structured TUI idle evidence', () => {
it('does not treat a ready prompt preview as proof that a turn is idle', () => {
const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace'
expect(readyPreview).toContain('OpenAI Codex')
expect(
hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false })
).toBe(false)
})
it('requires an explicit idle state and still rejects blocked prompts', () => {
expect(
hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true })
).toBe(true)
expect(
hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true })
).toBe(false)
})
it('does not authorize a restored idle status before live observation', () => {
expect(
hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false })
).toBe(false)
})
})
@@ -0,0 +1,9 @@
import type { AgentStatus } from '../../shared/agent-title-core'
export function hasStructuredTuiIdleEvidence(input: {
blocked: boolean
status: AgentStatus | null
statusObservedLive: boolean
}): boolean {
return !input.blocked && input.status === 'idle' && input.statusObservedLive
}
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity'
describe('structured TUI process identity', () => {
it('binds the direct Codex child instead of the PTY shell pid', async () => {
const readStartTime = vi.fn(async () => 1_700_000_000_000)
await expect(
readStructuredTuiProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
agent: 'codex',
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' },
{ pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' }
],
readStartTime
})
).resolves.toEqual({
hostId: 'local',
pid: 101,
processStartTimeMs: 1_700_000_000_000,
spawnToken: 'spawn-1'
})
expect(readStartTime).toHaveBeenCalledWith(101, 'darwin')
})
it('fails closed when sibling Codex children make the owner ambiguous', async () => {
await expect(
readStructuredTuiProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
agent: 'codex',
platform: 'win32',
readWindowsRows: async () => [
{ pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' },
{ pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' },
{ pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' }
]
})
).rejects.toThrow('one exact Codex child process')
})
it('fails closed when the process snapshot omitted the PTY root', async () => {
await expect(
readStructuredTuiProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
agent: 'codex',
platform: 'darwin',
readPosixRows: async () => [
{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' }
]
})
).rejects.toThrow('root process was not present')
})
})
@@ -0,0 +1,97 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import {
getFreshProcessTableSnapshot,
type ProcessTableRow
} from '../../shared/process-table-snapshot'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows'
import { readProcessStartTimeMs } from './agent-session-process-identity-probe'
type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean }
function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] {
const children = new Map<number, ProcessRow[]>()
for (const row of rows) {
children.set(row.ppid, [...(children.get(row.ppid) ?? []), row])
}
const found: (ProcessRow & { depth: number })[] = []
const pending = [{ pid: rootPid, depth: 0 }]
const seen = new Set<number>()
while (pending.length > 0) {
const current = pending.pop()!
if (seen.has(current.pid)) {
continue
}
seen.add(current.pid)
const row = rows.find((candidate) => candidate.pid === current.pid)
if (row) {
found.push({ ...row, depth: current.depth })
}
for (const child of children.get(current.pid) ?? []) {
pending.push({ pid: child.pid, depth: current.depth + 1 })
}
}
return found
}
export function resolveStructuredTuiChildPid(
rows: ProcessRow[],
rootPid: number,
agent: 'claude' | 'codex'
): number | null {
const candidates = descendants(rows, rootPid).filter(
(row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent
)
const foreground = candidates.filter((row) => row.foreground)
const eligible = foreground.length > 0 ? foreground : candidates
eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid)
if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) {
return null
}
return eligible[0]!.pid
}
function posixRows(rows: ProcessTableRow[]): ProcessRow[] {
return rows.map((row) => ({
pid: row.pid,
ppid: row.ppid,
command: row.command,
foreground: row.stat.includes('+')
}))
}
export async function readStructuredTuiProcessIdentity(input: {
hostId: string
rootPid: number
spawnToken: string
agent: 'claude' | 'codex'
platform?: NodeJS.Platform
readPosixRows?: () => Promise<ProcessTableRow[]>
readWindowsRows?: typeof queryWindowsProcessRowsFresh
readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise<number | null>
}): Promise<AgentSessionProcessIdentity> {
const platform = input.platform ?? process.platform
const rows: ProcessRow[] =
platform === 'win32'
? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({
pid: row.pid,
ppid: row.ppid,
command: row.command,
foreground: false
}))
: posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)())
if (!rows.some((row) => row.pid === input.rootPid)) {
throw new Error('The terminal root process was not present in the process snapshot.')
}
const pid = resolveStructuredTuiChildPid(rows, input.rootPid, input.agent)
if (pid === null) {
const agentName = input.agent === 'claude' ? 'Claude' : 'Codex'
throw new Error(`The resumed terminal did not expose one exact ${agentName} child process.`)
}
return {
hostId: input.hostId,
pid,
processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform),
spawnToken: input.spawnToken
}
}
@@ -17,6 +17,7 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context'
import { useStructuredAgentSession } from './use-structured-agent-session'
import { translate } from '@/i18n/i18n'
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
function encodeQuestionAnswer(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
@@ -99,6 +100,13 @@ export function NativeChatStructuredSession(props: {
tabIndex={-1}
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
>
<StructuredAgentSessionHandoffChrome
status={controller.handoff}
isWorking={controller.isWorking}
onRequest={(direction, mode, action) => {
void controller.requestHandoff(direction, mode, action)
}}
/>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -194,7 +202,9 @@ export function NativeChatStructuredSession(props: {
{controller.error ?? composerError}
</p>
) : null}
{prompt ? null : (
{prompt ||
controller.handoff?.owner === 'tui' ||
controller.handoff?.phase === 'switching' ? null : (
<NativeChatComposer
terminalTabId={props.tabId}
paneKey={paneKey}
@@ -0,0 +1,215 @@
import type {
AgentSessionHandoffDirection,
AgentSessionHandoffMode,
AgentSessionHandoffStatus
} from '../../../../shared/agent-session-wire'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { translate } from '@/i18n/i18n'
type Props = {
status: AgentSessionHandoffStatus | null
isWorking: boolean
onRequest: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
) => void
}
function handoffStageCopy(status: AgentSessionHandoffStatus): string {
if (status.stage === 'preparing') {
return status.direction === 'to-tui'
? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…')
: translate(
'components.native-chat.handoff.stage.finishingTerminal',
'Finishing agent terminal…'
)
}
if (status.stage === 'old-owner-stopped') {
return status.direction === 'to-tui'
? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…')
: translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…')
}
if (status.stage === 'new-owner-proving') {
return status.direction === 'to-tui'
? translate(
'components.native-chat.handoff.stage.verifyingTerminal',
'Verifying agent terminal…'
)
: translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…')
}
if (status.stage === 'recovering') {
return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…')
}
if (status.stage === 'manual-recovery') {
return translate(
'components.native-chat.handoff.stage.manualRecovery',
'Agent session needs recovery'
)
}
return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…')
}
export function StructuredAgentSessionHandoffChrome({
status,
isWorking,
onRequest
}: Props): React.JSX.Element | null {
if (!status) {
return null
}
const owner = status?.owner ?? 'native'
const phase = status?.phase ?? 'idle'
const switching = phase === 'switching' || phase === 'waiting-for-exit'
return (
<>
<div className="flex min-h-9 items-center gap-2 border-b border-border px-3 py-1.5">
<Badge variant="outline">
{switching
? translate('components.native-chat.handoff.mode.switching', 'Switching')
: owner === 'tui'
? translate('components.native-chat.handoff.mode.terminal', 'Terminal')
: translate('components.native-chat.handoff.mode.chat', 'Chat')}
</Badge>
<div className="ml-auto flex items-center gap-1.5">
{phase === 'queued' && status?.direction ? (
<>
<span className="text-xs text-muted-foreground">
{status.direction === 'to-tui'
? translate(
'components.native-chat.handoff.switchingAfterTurn',
'Switching after this turn'
)
: translate(
'components.native-chat.handoff.returningAfterTurn',
'Returning after this turn'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'after-turn', 'cancel-queued')}
>
{translate('components.native-chat.handoff.cancel', 'Cancel')}
</Button>
</>
) : owner === 'native' && !switching ? (
isWorking ? (
<>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate(
'components.native-chat.handoff.switchAfterTurn',
'Switch after this turn'
)}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onRequest('to-tui', 'stop-turn')}
>
{translate(
'components.native-chat.handoff.stopTurnAndSwitch',
'Stop turn and switch'
)}
</Button>
</>
) : (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-tui', 'now')}
>
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>
)
) : owner === 'tui' && !switching ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-native', 'after-turn')}
>
{isWorking
? translate(
'components.native-chat.handoff.returnAfterTurn',
'Return after this turn'
)
: translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
) : null}
</div>
</div>
{owner === 'tui' && phase === 'idle' ? (
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted px-3 py-2 text-xs">
<span>
{status?.hostLabel
? translate(
'components.native-chat.handoff.agentOpenOnHost',
'Agent is open in terminal on {{value0}}.',
{ value0: status.hostLabel }
)
: translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')}
</span>
<Button
type="button"
variant="link"
size="xs"
onClick={() => onRequest('to-native', 'after-turn')}
>
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
</div>
) : null}
{switching ? (
<div className="border-b border-border bg-muted px-3 py-3 text-center text-sm text-muted-foreground">
{phase === 'waiting-for-exit'
? translate(
'components.native-chat.handoff.exitTerminal',
'Exit the agent terminal to continue in chat.'
)
: status?.stage
? handoffStageCopy(status)
: translate(
'components.native-chat.handoff.switchingOwner',
'Switching session owner…'
)}
</div>
) : null}
{phase === 'failed' && status?.error ? (
<div
className="border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
role="alert"
>
<div className="flex items-center justify-between gap-3">
<span>{status.error.message}</span>
{status.direction && status.error.recoverableOwner !== 'none' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'now', 'retry')}
>
{translate('components.native-chat.handoff.retry', 'Retry')}
</Button>
) : null}
</div>
{status.error.details ? (
<details className="mt-1">
<summary>{translate('components.native-chat.handoff.details', 'Details')}</summary>
<p className="mt-1 text-muted-foreground">{status.error.details}</p>
</details>
) : null}
</div>
) : null}
</>
)
}
@@ -1,6 +1,9 @@
import { useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire'
import type {
AgentSessionHandoffStatus,
AgentSessionHistoryResult
} from '../../../../shared/agent-session-wire'
import {
projectStructuredAgentSessionStatus,
structuredAgentSessionPaneKey
@@ -19,6 +22,10 @@ import {
callStructuredAgentSession,
subscribeStructuredAgentSession
} from '@/runtime/structured-agent-session-client'
import {
clearStructuredHandoff,
publishStructuredHandoff
} from '@/runtime/structured-agent-session-handoff-store'
type StructuredTab = Tab & { contentType: 'agent-session' }
@@ -87,6 +94,13 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
target,
{ sessionId: tab.entityId, ...(state.cursor ? { cursor: state.cursor } : {}) },
(event) => {
if ('handoff' in event && event.handoff) {
publishStructuredHandoff({
sessionId: tab.entityId,
fence: event.fence ?? state.fence ?? 0,
status: event.handoff
})
}
if (
event.type === 'batch' &&
!shouldAdvanceStructuredResumeCursor(state.cursor, event.batch.cursor)
@@ -127,12 +141,17 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
opening = false
}
}
void callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId: tab.entityId,
direction: 'tail',
limit: 40
})
.then(async (result) => {
void Promise.all([
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId: tab.entityId,
direction: 'tail',
limit: 40
}),
callStructuredAgentSession<AgentSessionHandoffStatus>(target, 'agentSession.handoffStatus', {
sessionId: tab.entityId
}).catch(() => null)
])
.then(async ([result, handoff]) => {
if (stopped) {
return
}
@@ -150,6 +169,14 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
}
})
}
if (handoff) {
apply({ type: 'handoff', handoff })
publishStructuredHandoff({
sessionId: tab.entityId,
fence: result.ok ? (result.page.fence ?? 0) : (result.fence ?? 0),
status: handoff
})
}
await open()
})
.catch(scheduleReconnect)
@@ -159,6 +186,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
clearTimeout(reconnectTimer)
}
unsubscribe()
clearStructuredHandoff(tab.entityId)
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId))
}
}
@@ -21,7 +21,10 @@ import { recordWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
const LOCAL_OWNER = 'local-structured-session'
export function useStructuredAgentSessionCreate(worktreeId: string): {
export function useStructuredAgentSessionCreate(
worktreeId: string,
agent: 'claude' | 'codex'
): {
supported: boolean
creating: boolean
create: () => Promise<boolean>
@@ -55,14 +58,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
const support = await callStructuredAgentSession<{ supported?: boolean }>(
target,
'agentSession.createSupport',
{ worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' }
{ worktree: toRuntimeWorktreeSelector(worktreeId), agent }
)
if (!stale) {
setSupported(
showStructuredAgentSessionChoice({
hostCapability,
workspaceSupport: support.supported === true,
agent: 'codex'
agent
})
)
}
@@ -70,7 +73,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
return () => {
stale = true
}
}, [target, worktreeId])
}, [agent, target, worktreeId])
const create = useCallback(async (): Promise<boolean> => {
if (!supported || creating) {
@@ -92,10 +95,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
sessionId,
clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
expectedRuntimeFence: null,
payloadFingerprint: structuredAgentSessionCreateFingerprint({ sessionId, worktree })
payloadFingerprint: structuredAgentSessionCreateFingerprint({
sessionId,
worktree,
agent
})
},
worktree,
agent: 'codex'
agent
})
if (!result.ok) {
throw new Error(result.refusal.message)
@@ -104,7 +111,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): {
} finally {
setCreating(false)
}
}, [creating, supported, target, worktreeId])
}, [agent, creating, supported, target, worktreeId])
return { supported, creating, create }
}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type {
AgentSessionHandoffStatus,
AgentSessionHistoryResult,
AgentSessionSubscribeEvent
} from '../../../../shared/agent-session-wire'
@@ -129,11 +130,18 @@ export function useStructuredAgentSessionRead(args: {
}
}
async function refreshTail(): Promise<void> {
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
target,
'agentSession.history',
{ sessionId, direction: 'tail', limit: 40 }
)
const [result, handoff] = await Promise.all([
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId,
direction: 'tail',
limit: 40
}),
callStructuredAgentSession<AgentSessionHandoffStatus>(
target,
'agentSession.handoffStatus',
{ sessionId }
).catch(() => null)
])
if (stopped) {
return
}
@@ -153,6 +161,9 @@ export function useStructuredAgentSessionRead(args: {
})
resumeCursorRef.current = result.snapshot.cursor
}
if (handoff) {
dispatch({ type: 'handoff', handoff })
}
}
const refreshOnFocus = (): void => {
if (!document.hasFocus()) {
@@ -3,6 +3,9 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo
import type { AgentType } from '../../../../shared/agent-status-types'
import type {
AgentSessionMutationResult,
AgentSessionHandoffDirection,
AgentSessionHandoffMode,
AgentSessionHandoffResult,
AgentSessionOptionsResult,
AgentSessionPromptResult
} from '../../../../shared/agent-session-wire'
@@ -61,13 +64,15 @@ export function useStructuredAgentSession(args: {
async <T>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
fields: Record<string, unknown>,
operationIdOverride?: string | null
): Promise<T | null> => {
if (stateRef.current.fence === null) {
return null
}
const key = `${fingerprintMethod}:${JSON.stringify(fields)}`
const clientOperationId = operationIds.current.get(key) ?? structuredSessionOperationId()
const clientOperationId =
operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId()
operationIds.current.set(key, clientOperationId)
const result = await callStructuredAgentSession<AgentSessionMutationResult<T>>(
target,
@@ -204,6 +209,18 @@ export function useStructuredAgentSession(args: {
),
optionSnapshot,
optionSurface,
setStructuredOption
setStructuredOption,
requestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action: 'start' | 'cancel-queued' | 'retry' = 'start'
) =>
mutate<AgentSessionHandoffResult>(
'agentSession.requestHandoff',
'agentSession.requestHandoff',
{ direction, mode, action },
action === 'retry' ? stateRef.current.handoff?.operationId : null
),
handoff: state.handoff
}
}
@@ -8,7 +8,8 @@ const {
storeState,
openSettingsPageMock,
openSettingsTargetMock,
useDetectedAgentsMock
useDetectedAgentsMock,
useStructuredAgentSessionCreateMock
} = vi.hoisted(() => ({
shortcutLabelMock: vi.fn<() => string | null>(),
storeState: {
@@ -23,7 +24,12 @@ const {
},
openSettingsPageMock: vi.fn(),
openSettingsTargetMock: vi.fn(),
useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] }))
useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })),
useStructuredAgentSessionCreateMock: vi.fn(() => ({
supported: true,
creating: false,
create: vi.fn(async () => true)
}))
}))
vi.mock('@/hooks/useDetectedAgents', () => ({
@@ -34,6 +40,10 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
useOptionalShortcutLabel: shortcutLabelMock
}))
vi.mock('../native-chat/use-structured-agent-session-create', () => ({
useStructuredAgentSessionCreate: useStructuredAgentSessionCreateMock
}))
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof storeState) => unknown) => {
@@ -117,6 +127,7 @@ beforeEach(() => {
shortcutLabelMock.mockReset()
shortcutLabelMock.mockReturnValue(null)
useDetectedAgentsMock.mockClear()
useStructuredAgentSessionCreateMock.mockClear()
openSettingsPageMock.mockReset()
openSettingsTargetMock.mockReset()
storeState.settings.defaultTuiAgent = 'codex'
@@ -206,6 +217,16 @@ describe('QuickLaunchAgentMenuItems', () => {
storeState.settings.defaultTuiAgent = 'blank'
expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"')
})
it('offers explicit structured chat creation for Claude and Codex', () => {
const html = renderAgentMenuItems()
expect(html.match(/Chat session/g) ?? []).toHaveLength(2)
expect(html).toContain('Start Claude without a terminal')
expect(html).toContain('Start Codex without a terminal')
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'claude')
expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'codex')
})
})
describe('shouldShowLaunchWatchdogTimeout', () => {
@@ -117,7 +117,8 @@ function QuickLaunchAgentMenuItemsInner({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
const structuredSession = useStructuredAgentSessionCreate(worktreeId)
const claudeStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'claude')
const codexStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'codex')
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
@@ -199,6 +200,12 @@ function QuickLaunchAgentMenuItemsInner({
{agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const structuredSession =
agent === 'claude'
? claudeStructuredSession
: agent === 'codex'
? codexStructuredSession
: null
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
@@ -218,7 +225,7 @@ function QuickLaunchAgentMenuItemsInner({
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
{agent === 'codex' && structuredSession.supported ? (
{structuredSession?.supported ? (
<DropdownMenuItem
disabled={structuredSession.creating}
onSelect={() => {
@@ -235,7 +242,8 @@ function QuickLaunchAgentMenuItemsInner({
className="gap-2 rounded-[7px] px-2 py-1.5 pl-6 text-[12px] leading-5 font-medium"
title={translate(
'auto.components.tab.bar.QuickLaunchButton.a847779775',
'Start Codex without a terminal'
'Start {{value0}} without a terminal',
{ value0: label }
)}
>
<MessageSquare className="size-3.5 text-muted-foreground" />
@@ -179,7 +179,12 @@ export default function AiVaultSessionDropLayer({
if (payload.structuredSession) {
const { sessionId, workspaceId } = payload.structuredSession
if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) {
toast.error('The structured agent session is not available yet. Retry in a moment.')
toast.error(
translate(
'auto.lib.activateAiVaultStructuredSession.unavailable',
'The structured agent session is not available yet. Retry in a moment.'
)
)
}
return true
}
@@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export function StructuredAgentSessionTerminalReturnButton(props: {
enabled: boolean
onReturn?: () => void
}): React.JSX.Element | null {
if (!props.enabled) {
return null
}
return (
<Button
type="button"
variant="ghost"
size="xs"
className="pane-title-split-trigger"
onClick={(event) => {
event.stopPropagation()
props.onReturn?.()
}}
>
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
)
}
@@ -243,6 +243,13 @@ import {
} from './regular-terminal-focus-ownership'
import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts'
import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh'
import { useTerminalStructuredHandoff } from '@/runtime/structured-agent-session-handoff-store'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
createStructuredAgentSessionOperationId,
structuredAgentSessionPayloadFingerprint
} from '../../../../shared/structured-agent-session-mutation'
type TerminalPaneProps = {
tabId: string
@@ -340,6 +347,34 @@ function TerminalPane(
const isRendererVisible = isVisible && isWorktreeActive
const isVisibleRef = useRef(isRendererVisible)
isVisibleRef.current = isRendererVisible
const structuredHandoff = useTerminalStructuredHandoff(tabId)
const returnStructuredSession = useCallback(() => {
if (!structuredHandoff) {
return
}
const direction = 'to-native' as const
const mode = 'after-turn' as const
const action = 'start' as const
const sessionId = structuredHandoff.sessionId
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId })
const clientOperationId = createStructuredAgentSessionOperationId(() => crypto.randomUUID())
void callStructuredAgentSession(target, 'agentSession.requestHandoff', {
envelope: {
sessionId,
clientOperationId,
expectedRuntimeFence: structuredHandoff.fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.requestHandoff',
sessionId,
fields: { direction, mode, action }
})
},
direction,
mode,
action
})
}, [structuredHandoff, worktreeId])
const {
nativeChatTranscriptIsLocalReadable,
sshReconnectEnvironmentId,
@@ -3174,6 +3209,8 @@ function TerminalPane(
canToggleNativeChat={activePaneCanToggleChat}
isChatViewMode={activePaneIsChatLeaf}
onToggleNativeChat={handleToggleNativeChat}
canReturnStructuredSession={structuredHandoff?.status.owner === 'tui'}
onReturnStructuredSession={returnStructuredSession}
canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession}
onContinueAgentSessionInNewSession={(pane) =>
contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession)
@@ -8,6 +8,7 @@ import {
} from 'lucide-react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { Button } from '@/components/ui/button'
import { StructuredAgentSessionTerminalReturnButton } from './StructuredAgentSessionTerminalReturnButton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
@@ -50,6 +51,8 @@ type TerminalPaneHeaderOverlayProps = {
isChatViewMode?: boolean
/** Flip the active pane between the terminal and the native chat view. */
onToggleNativeChat?: () => void
canReturnStructuredSession?: boolean
onReturnStructuredSession?: () => void
canContinueAgentSessionInNewSession?: boolean
onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void
onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void
@@ -88,6 +91,8 @@ export default function TerminalPaneHeaderOverlay({
canToggleNativeChat,
isChatViewMode,
onToggleNativeChat,
canReturnStructuredSession,
onReturnStructuredSession,
canContinueAgentSessionInNewSession,
onContinueAgentSessionInNewSession,
onSplitPane,
@@ -244,6 +249,10 @@ export default function TerminalPaneHeaderOverlay({
</button>
) : null}
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
<StructuredAgentSessionTerminalReturnButton
enabled={Boolean(canReturnStructuredSession && isActivePane)}
onReturn={onReturnStructuredSession}
/>
{canContinueAgentSessionInNewSession && isActivePane ? (
<Tooltip>
<TooltipTrigger asChild>
+34
View File
@@ -769,6 +769,9 @@
"additionalErrors": "{{count}} additional images could not be attached."
}
}
},
"activateAiVaultStructuredSession": {
"unavailable": "The structured agent session is not available yet. Retry in a moment."
}
},
"hooks": {
@@ -15116,6 +15119,37 @@
"status": {
"responding": "Agent is responding"
},
"handoff": {
"mode": {
"switching": "Switching",
"terminal": "Terminal",
"chat": "Chat"
},
"switchingAfterTurn": "Switching after this turn",
"returningAfterTurn": "Returning after this turn",
"cancel": "Cancel",
"switchAfterTurn": "Switch after this turn",
"stopTurnAndSwitch": "Stop turn and switch",
"openAgentTui": "Open agent TUI",
"returnAfterTurn": "Return after this turn",
"returnToChat": "Return to chat",
"agentOpen": "Agent is open in terminal.",
"agentOpenOnHost": "Agent is open in terminal on {{value0}}.",
"exitTerminal": "Exit the agent terminal to continue in chat.",
"switchingOwner": "Switching session owner…",
"retry": "Retry",
"details": "Details",
"stage": {
"finishingChat": "Finishing chat session…",
"finishingTerminal": "Finishing agent terminal…",
"openingTerminal": "Opening agent terminal…",
"resumingChat": "Resuming chat session…",
"verifyingTerminal": "Verifying agent terminal…",
"verifyingChat": "Verifying chat session…",
"recovering": "Recovering agent session…",
"manualRecovery": "Agent session needs recovery"
}
},
"jumpToLatest": "Jump to latest",
"toggle": {
"showTerminal": "Show terminal",
@@ -1,5 +1,6 @@
import { toast } from 'sonner'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { activateAndRevealWorktree } from './worktree-activation'
import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation'
import { useAppStore } from '@/store'
@@ -15,7 +16,12 @@ export function activateAiVaultStructuredSession(session: AiVaultSession): boole
sessionId: structured.sessionId
})
) {
toast.error('The structured agent session is not available yet. Retry in a moment.')
toast.error(
translate(
'auto.lib.activateAiVaultStructuredSession.unavailable',
'The structured agent session is not available yet. Retry in a moment.'
)
)
return true
}
if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) {
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync'
describe('local structured session tab projection', () => {
it('keeps only structured tabs without replaying renderer-owned groups', () => {
const snapshot = {
worktree: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'agent-session:claude-1',
activeTabType: 'agent-session',
tabGroups: [
{
id: 'group-1',
activeTabId: 'agent-session:claude-1',
tabOrder: ['terminal-1', 'agent-session:claude-1']
}
],
tabs: [
{
type: 'terminal',
id: 'terminal-1',
parentTabId: 'terminal-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready',
terminal: 'term-1',
ptyId: 'pty-1',
isActive: false
},
{
type: 'agent-session',
id: 'agent-session:claude-1',
title: 'Claude Chat',
sessionId: 'claude-1',
agent: 'claude',
isActive: true
}
]
} satisfies RuntimeMobileSessionTabsResult
expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({
tabGroups: undefined,
tabs: [expect.objectContaining({ type: 'agent-session', agent: 'claude' })]
})
})
})
@@ -11,20 +11,17 @@ type SessionTabsEvent =
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
| { type: 'end' }
function structuredOnly(snapshot: RuntimeMobileSessionTabsResult): RuntimeMobileSessionTabsResult {
export function projectLocalStructuredSessionTabs(
snapshot: RuntimeMobileSessionTabsResult
): RuntimeMobileSessionTabsResult {
const structuredIds = new Set(
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
)
return {
...snapshot,
tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)),
tabGroups: snapshot.tabGroups?.map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)),
activeTabId:
group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null,
recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id))
}))
// Why: replaying local terminal group ids under the structured owner clones the workspace layout.
tabGroups: undefined
}
}
@@ -34,7 +31,7 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v
for (const snapshot of snapshots) {
const patch = applyWebSessionTabsSnapshot(
next,
structuredOnly(snapshot),
projectLocalStructuredSessionTabs(snapshot),
LOCAL_STRUCTURED_SESSION_OWNER
)
next = patch === next ? next : ({ ...next, ...patch } as typeof state)
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
export type TerminalStructuredHandoff = {
sessionId: string
fence: number
status: AgentSessionHandoffStatus
}
const byTerminalTabId = new Map<string, TerminalStructuredHandoff>()
const listeners = new Set<() => void>()
export function publishStructuredHandoff(input: TerminalStructuredHandoff): void {
for (const [tabId, current] of byTerminalTabId) {
if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) {
byTerminalTabId.delete(tabId)
}
}
if (input.status.terminal) {
byTerminalTabId.set(input.status.terminal.tabId, input)
}
for (const listener of listeners) {
listener()
}
}
export function clearStructuredHandoff(sessionId: string): void {
let changed = false
for (const [tabId, current] of byTerminalTabId) {
if (current.sessionId === sessionId) {
byTerminalTabId.delete(tabId)
changed = true
}
}
if (changed) {
for (const listener of listeners) {
listener()
}
}
}
export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => byTerminalTabId.get(tabId) ?? null
)
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { Tab } from '../../../shared/types'
import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync'
const WORKTREE_ID = 'repo-1::/worktree'
const GROUP_ID = 'group-1'
function structuredTab(sessionId: string, sortOrder: number): Tab {
return {
id: `structured-agent-session-${sessionId}`,
entityId: sessionId,
groupId: GROUP_ID,
worktreeId: WORKTREE_ID,
contentType: 'agent-session',
agentSessionAgent: 'codex',
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder,
createdAt: sortOrder + 1
}
}
describe('web session structured tab focus', () => {
it('keeps the exact active structured tab across a host snapshot', () => {
const first = structuredTab('session-1', 0)
const second = structuredTab('session-2', 1)
const state = {
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID },
activeTabId: null,
activeTabIdByWorktree: {},
activeTabType: 'terminal',
activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' },
activeWorktreeId: WORKTREE_ID,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
browserCertificateFailuresByPageId: {},
browserPagesByWorkspace: {},
browserTabsByWorktree: {},
groupsByWorktree: {
[WORKTREE_ID]: [
{
id: GROUP_ID,
worktreeId: WORKTREE_ID,
activeTabId: second.id,
tabOrder: [first.id, second.id]
}
]
},
layoutByWorktree: {},
openFiles: [],
ptyIdsByTabId: {},
remoteBrowserPageHandlesByPageId: {},
tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] },
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] },
unreadTerminalTabs: {},
sortEpoch: 0
} as WebSessionTabsSyncState
const snapshot: RuntimeMobileSessionTabsResult = {
worktree: WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 2,
activeGroupId: GROUP_ID,
activeTabId: 'agent-session:session-1',
activeTabType: 'agent-session',
tabGroups: [
{
id: GROUP_ID,
activeTabId: 'agent-session:session-1',
tabOrder: ['agent-session:session-1', 'agent-session:session-2']
}
],
tabs: [
{
type: 'agent-session',
id: 'agent-session:session-1',
title: 'Codex Chat',
sessionId: 'session-1',
agent: 'codex',
isActive: true
},
{
type: 'agent-session',
id: 'agent-session:session-2',
title: 'Codex Chat',
sessionId: 'session-2',
agent: 'codex',
isActive: false
}
]
}
const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10)
expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id)
})
})
@@ -2295,6 +2295,18 @@ function findCurrentVisibleUnifiedTabId(args: {
if (!nextUnifiedTabs) {
return null
}
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
const activeGroupTabId = state.groupsByWorktree[worktreeId]?.find(
(group) => group.id === activeGroupId
)?.activeTabId
if (
activeGroupTabId &&
nextUnifiedTabs.some(
(tab) => tab.id === activeGroupTabId && tab.contentType === 'agent-session'
)
) {
return activeGroupTabId
}
const currentVisibleType =
state.activeTabTypeByWorktree[worktreeId] ??
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
@@ -137,7 +137,12 @@ export function evaluateAgentSessionAcquisition(args: {
// Why: the retry key is operation id + fence + stage; a different id is a different intent.
return { decision: 'refused', code: 'agent_session_operation_conflict' }
}
if (lease.ownerProcess === null && lease.handoffStage === 'old-owner-stopped') {
if (
lease.ownerProcess === null &&
lease.handoffStage === 'old-owner-stopped' &&
lease.claimStatus === 'reserved' &&
lease.reservedSpawnToken !== null
) {
// Why: an idempotent re-run of a reservation that already exists at this fence.
return { decision: 'retry-reservation', fence: lease.runtimeFence }
}
@@ -129,7 +129,7 @@ export function admitAgentSessionMutation(input: {
/** Why the single admission oracle said no, mapped to what the client can do
* about it. The predicate itself is never re-implemented here. */
function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null {
if (agentSessionLeaseAdmitsWriter(lease)) {
if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) {
return null
}
if (lease.unreconciled) {
@@ -144,6 +144,12 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR
message: `The session is mid-handoff (${lease.handoffStage}).`
}
}
if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) {
return {
code: 'agent_session_conflict',
message: 'The agent terminal owns this session.'
}
}
return {
code: 'agent_session_ownership_unknown',
message: 'The session has no live owner to accept writes.'

Some files were not shown because too many files have changed in this diff Show More