From 23ce5e5a670cedeed6d2ce6737c9eef03a02acb5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:00:26 -0700 Subject: [PATCH] feat: wire Claude structured sessions --- .../claude-structured-item-translation.ts | 158 ++++++++ ...ude-structured-journal-translation.test.ts | 212 +++++++++++ .../claude-structured-journal-translation.ts | 275 ++++++++++++++ .../claude/claude-structured-prompt-items.ts | 108 ++++++ .../claude-structured-session-adapter.ts | 10 +- .../claude/claude-structured-session-close.ts | 13 +- .../claude-structured-session-publication.ts | 3 + .../claude/claude-structured-session-state.ts | 2 + ...structured-agent-session-adapter-router.ts | 89 +++++ .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-host.ts | 17 +- ...ructured-agent-session-provider-routing.ts | 35 ++ .../structured-agent-session-read-restore.ts | 6 +- .../runtime/agent-session-claim-key-state.ts | 46 +++ ...session-provider-handle-transition.test.ts | 48 +++ ...gent-session-provider-handle-transition.ts | 41 ++ .../runtime/agent-session-record-store.ts | 41 +- ...ude-structured-session-integration.test.ts | 358 ++++++++++++++++++ ...ctured-agent-session-create-intent.test.ts | 52 +++ src/main/runtime/orca-runtime.ts | 41 +- .../structured-agent-session-schemas.ts | 4 +- .../rpc/methods/structured-agent-session.ts | 2 +- .../structured-agent-session-runtime.ts | 45 ++- src/shared/runtime-types.ts | 2 +- 24 files changed, 1547 insertions(+), 63 deletions(-) create mode 100644 src/main/claude/claude-structured-item-translation.ts create mode 100644 src/main/claude/claude-structured-journal-translation.test.ts create mode 100644 src/main/claude/claude-structured-journal-translation.ts create mode 100644 src/main/claude/claude-structured-prompt-items.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts create mode 100644 src/main/runtime/agent-session-claim-key-state.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.test.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.ts create mode 100644 src/main/runtime/claude-structured-session-integration.test.ts diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts new file mode 100644 index 00000000000..a69130f5c98 --- /dev/null +++ b/src/main/claude/claude-structured-item-translation.ts @@ -0,0 +1,158 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +export type ClaudeMessageEnvelope = { + sessionId: string + uuid: string + role: 'assistant' | 'user' + content: unknown[] +} + +export type ClaudeToolUse = { id: string; name: string; input: unknown } +export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean } + +export function claudeRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function claudeText(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeMessageEnvelope( + frame: Record +): ClaudeMessageEnvelope | null { + if (frame.type !== 'assistant' && frame.type !== 'user') { + return null + } + const message = claudeRecord(frame.message) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + const role = message?.role + return sessionId && uuid && (role === 'assistant' || role === 'user') + ? { + sessionId, + uuid, + role, + content: Array.isArray(message?.content) ? message.content : [] + } + : null +} + +export function claudeMessageIdentity( + envelope: Pick +): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } +} + +function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const value of envelope.content) { + const part = claudeRecord(value) + const text = claudeText(part?.text) + if (part?.type === 'text' && text) { + blocks.push({ type: 'text', text }) + continue + } + const source = claudeRecord(part?.source) + const url = claudeText(source?.url) + if (part?.type === 'image' && source?.type === 'url' && url) { + blocks.push({ type: 'image-ref', url }) + } + } + return blocks +} + +export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null { + const blocks = messageBlocks(envelope) + return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null +} + +export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const id = claudeText(part?.id) + const name = claudeText(part?.name) + return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : [] + }) +} + +function resultText(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return value === undefined ? '' : JSON.stringify(value) + } + return value + .flatMap((entry) => { + if (typeof entry === 'string') { + return [entry] + } + const part = claudeRecord(entry) + return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [] + }) + .join('\n') +} + +export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const toolUseId = claudeText(part?.tool_use_id) + return part?.type === 'tool_result' && toolUseId + ? [ + { + toolUseId, + output: resultText(part.content), + failed: part.is_error === true + } + ] + : [] + }) +} + +export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null { + const parts = envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const thinking = claudeText(part?.thinking) + return part?.type === 'thinking' && thinking ? [thinking] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +export function claudeToolBody(input: { + tool: ClaudeToolUse + result?: ClaudeToolResult +}): AgentJournalItemBody { + return { + kind: 'tool-call', + name: input.tool.name, + input: input.tool.input, + state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', + ...(input.result + ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } + : {}) + } +} + +export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` } +} + +export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts new file mode 100644 index 00000000000..e29310432a2 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + return { sink, items, tombstones } +} + +function message( + type: 'assistant' | 'user', + uuid: string, + content: unknown[], + parentToolUseId: string | null = null +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type, + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { role: type, content } + } + } +} + +describe('Claude structured journal translation', () => { + it('reuses the shared coalescer and finalizes the provider-keyed message row', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run, delay) => { + expect(delay).toBe(60) + scheduled = run + return () => { + scheduled = null + } + } + }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: 'assistant-1', + session_id: 'claude-session', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } } + } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: 'assistant-1', + session_id: 'claude-session', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } } + } + }) + expect(state.items).toEqual([]) + + const run = scheduled as (() => void) | null + run?.() + expect(state.items.at(-1)).toEqual({ + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'assistant-1' }, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello' }] } + }) + + translator.handle(message('assistant', 'assistant-1', [{ type: 'text', text: 'Hello!' }])) + expect(state.items.at(-1)).toEqual({ + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'assistant-1' }, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello!' }] } + }) + }) + + it('journals turn lifecycle and updates one tool row through its result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }])) + translator.handle( + message('assistant', 'assistant-tool', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } } + ]) + ) + translator.handle( + message( + 'user', + 'tool-result-1', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }], + 'tool-1' + ) + ) + + const keyed = new Map( + state.items.map((item) => [agentJournalItemKey(item.identity), item.body]) + ) + expect(keyed.get('claude:claude-session:user-1')).toMatchObject({ + kind: 'message', + role: 'user' + }) + expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ + kind: 'tool-call', + name: 'Bash', + state: 'completed', + output: { head: 'a.ts\nb.ts', truncated: false } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1' + ) + ).toBe(true) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' } + }) + expect(state.tombstones.at(-1)).toMatchObject({ + provider: 'legacy', + agent: 'claude', + recordId: 'turn-lifecycle:user-1' + }) + }) + + it('creates addressable approval and multi-question cards and cancels them durably', () => { + const state = sinkState() + const bindings: unknown[][] = [] + const translator = createClaudeJournalTranslator({ + sink: state.sink, + bindPromptItemId: (...args) => bindings.push(args) + }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'approval', + title: 'Allow Bash?', + options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }]) + }) + expect(bindings[0]).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Apermission-1', + 'permission-1' + ]) + + const questions = prompt({ + requestId: 'questions-1', + promptKey: 'questions-1', + toolUseId: 'tool-q', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship?', options: [{ label: 'Yes' }] } + ] + }, + questionIds: ['Library?', 'Ship?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions }) + expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(2) + expect(bindings.at(-1)).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Aquestions-1%3AShip%3F', + 'questions-1', + 'Ship?' + ]) + + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: 'questions-1' + }) + expect(state.tombstones).toHaveLength(2) + }) +}) + +function prompt( + input: Pick< + ClaudePendingPrompt, + 'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds' + > +): ClaudePendingPrompt { + return { + ...input, + suggestions: [], + answers: new Map(), + request: { subtype: 'can_use_tool' } + } +} diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts new file mode 100644 index 00000000000..5a7d71de94f --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -0,0 +1,275 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + claudeMessageBody, + claudeMessageIdentity, + claudeRecord, + claudeStreamingMessageBody, + claudeText, + claudeThinkingIdentity, + claudeThinkingText, + claudeToolBody, + claudeToolIdentity, + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems +} from './claude-structured-prompt-items' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' + +export type ClaudeJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type ClaudeJournalTranslator = { + handle: (event: ClaudeStructuredSessionEvent) => void + flush: () => void + dispose: () => void +} + +export function createClaudeSessionJournalTranslator( + sink: StructuredAgentSessionEventSink | undefined, + prompts: ClaudePromptRegistry +): ClaudeJournalTranslator | null { + return sink + ? createClaudeJournalTranslator({ + sink, + bindPromptItemId: (itemId, promptKey, questionId) => + prompts.bindJournalItemId(itemId, promptKey, questionId) + }) + : null +} + +function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'claude', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +function streamDelta(message: Record): string | null { + if (message.type !== 'stream_event') { + return null + } + const event = claudeRecord(message.event) + if (event?.type === 'content_block_start') { + return claudeText(claudeRecord(event.content_block)?.text) + } + if (event?.type !== 'content_block_delta') { + return null + } + const delta = claudeRecord(event.delta) + return delta?.type === 'text_delta' ? claudeText(delta.text) : null +} + +export function createClaudeJournalTranslator( + deps: ClaudeJournalTranslatorDeps +): ClaudeJournalTranslator { + const tools = new Map() + const promptItems = new Map() + const streamIdentities = new Map() + const latestStreamText = new Map() + const checkpointLengths = new Map() + let currentTurn: { sessionId: string; turnId: string } | null = null + + const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { + const identity = lifecycleIdentity(sessionId, turnId) + if (running) { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId, state: 'running' } + }) + } else { + deps.sink.appendTombstone(identity) + } + deps.sink.publish() + } + + const persistStream = (key: string, text: string, force: boolean): void => { + latestStreamText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + const identity = streamIdentities.get(key) + if (!identity) { + return + } + checkpointLengths.set(key, text.length) + deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) + deps.sink.publish() + } + + const coalescer = createAgentSessionDeltaCoalescer({ + windowMs: deps.coalesceMs, + schedule: deps.schedule, + emit: (key, text) => persistStream(key, text, false) + }) + + const flushStreams = (): void => { + coalescer.flushAll() + for (const [key, text] of latestStreamText) { + if (checkpointLengths.get(key) !== text.length) { + persistStream(key, text, true) + } + } + } + + const handleStream = (message: Record): boolean => { + const delta = streamDelta(message) + const sessionId = claudeText(message.session_id) + const uuid = claudeText(message.uuid) + if (!delta || !sessionId || !uuid) { + return false + } + const key = agentJournalItemKey({ provider: 'claude', sessionId, uuid }) + streamIdentities.set(key, { provider: 'claude', sessionId, uuid }) + coalescer.append(key, delta) + return true + } + + const handleMessage = (message: Record): void => { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return + } + const identity = claudeMessageIdentity(envelope) + const key = agentJournalItemKey(identity) + coalescer.forget(key) + latestStreamText.delete(key) + checkpointLengths.delete(key) + streamIdentities.delete(key) + let changed = false + const body = claudeMessageBody(envelope) + if (body) { + deps.sink.appendItem(identity, body) + changed = true + } + for (const tool of claudeToolUses(envelope)) { + tools.set(tool.id, tool) + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, tool.id), + claudeToolBody({ tool }) + ) + changed = true + } + for (const result of claudeToolResults(envelope)) { + const tool = tools.get(result.toolUseId) ?? { + id: result.toolUseId, + name: 'tool', + input: null + } + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, result.toolUseId), + claudeToolBody({ tool, result }) + ) + changed = true + } + const thinking = claudeThinkingText(envelope) + if (thinking) { + deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'status', + text: thinking + }) + changed = true + } + if (envelope.role === 'user' && body && message.parent_tool_use_id === null) { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + } + currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } + publishLifecycle(envelope.sessionId, envelope.uuid, true) + } + if (changed) { + deps.sink.publish() + } + } + + const handlePrompt = (event: Extract): void => { + const identities: AgentJournalItemIdentity[] = [] + if (event.prompt.kind === 'question') { + for (const question of claudeQuestionItems({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + identities.push(question.identity) + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.( + agentJournalItemKey(question.identity), + event.prompt.promptKey, + question.questionId + ) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + identities.push(identity) + deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + promptItems.set(event.prompt.promptKey, identities) + deps.sink.publish() + } + + return { + handle: (event) => { + if (event.type === 'ended') { + flushStreams() + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + return + } + if (event.type === 'message' && handleStream(event.message)) { + return + } + flushStreams() + if (event.type === 'prompt') { + handlePrompt(event) + } else if (event.type === 'prompt-cancelled') { + for (const identity of promptItems.get(event.promptKey) ?? []) { + deps.sink.appendTombstone(identity) + } + promptItems.delete(event.promptKey) + deps.sink.publish() + } else if (event.type === 'message' && event.message.type === 'result') { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + } else if (event.type === 'message') { + handleMessage(event.message) + } + }, + flush: flushStreams, + dispose: () => { + coalescer.dispose() + tools.clear() + promptItems.clear() + streamIdentities.clear() + latestStreamText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts new file mode 100644 index 00000000000..e568c0d383f --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -0,0 +1,108 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + CLAUDE_APPROVAL_DECISIONS, + encodeClaudeQuestionOptionId, + type ClaudeApprovalDecision, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +const APPROVAL_LABELS: Record = { + allow: 'Allow', + allowForSession: 'Allow for this session', + deny: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function claudePromptIdentity(input: { + sessionId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `claude-prompt:${input.sessionId}:${input.promptKey}${suffix}` + } +} + +export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { + const serialized = JSON.stringify(prompt.input) + return { + kind: 'approval', + title: `Allow ${prompt.toolName}?`, + detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null, + options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ + id: decision, + label: APPROVAL_LABELS[decision] + })), + resolution: { ...PENDING } + } +} + +export type ClaudeQuestionItem = { + questionId: string + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +function questionOptions( + question: Record, + questionId: string +): AgentJournalPromptOption[] { + if (!Array.isArray(question.options)) { + return [] + } + return question.options.flatMap((value) => { + const option = claudeRecord(value) + const label = claudeText(option?.label) + return label ? [{ id: encodeClaudeQuestionOptionId(questionId, label), label }] : [] + }) +} + +export function claudeQuestionItems(input: { + sessionId: string + prompt: ClaudePendingPrompt +}): ClaudeQuestionItem[] { + const questions = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : [] + return questions.flatMap((value, index) => { + const question = claudeRecord(value) + const questionId = input.prompt.questionIds[index] + const text = claudeText(question?.question) ?? claudeText(question?.header) + return question && questionId && text + ? [ + { + questionId, + identity: claudePromptIdentity({ + sessionId: input.sessionId, + promptKey: input.prompt.promptKey, + questionId + }), + body: { + kind: 'question', + question: text, + options: questionOptions(question, questionId), + freeTextQuestionId: questionId, + resolution: { ...PENDING } + } + } + ] + : [] + }) +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 8a874132419..d4df1d9b6f3 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -24,6 +24,7 @@ import { setClaudeStructuredOption } from './claude-structured-options' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' +import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { cancelClaudeAcquisitionAttempt, ClaudeAcquisitionRegistry, @@ -34,7 +35,7 @@ import { } from './claude-structured-session-state' import { closeClaudePublishedSession, - settleClaudeDispatchWaiters + settleClaudeExitedSession } from './claude-structured-session-close' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' @@ -63,6 +64,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }): Promise { const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() + const translator = createClaudeSessionJournalTranslator(input.events, prompts) const { previous, attempt } = this.acquisitions.start(sessionId, prompts) let liveSession: ClaudeSession | null = null let observedLeafUuid: string | null = null @@ -183,6 +185,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda fence: input.fence, resumed: launch.resumed, prompts, + translator, events: input.events, process, ...(this.deps.mintLinkId ? { linkId: this.deps.mintLinkId() } : {}), @@ -202,6 +205,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda initDeadline.clear() this.acquisitions.deleteIfCurrent(sessionId, attempt) if (this.sessions.get(sessionId)?.connection !== attempt.connection) { + translator?.dispose() prompts.clear() await attempt.connection?.close() } @@ -227,9 +231,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return } this.sessions.delete(sessionId) - settleClaudeDispatchWaiters(session) - session.prompts.clear() this.emit(session, session.events, { type: 'ended', sessionId, reason: error.message }) + settleClaudeExitedSession(session) } private emit( @@ -237,6 +240,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda _events: StructuredAgentSessionEventSink | undefined, event: ClaudeStructuredSessionEvent ): void { + _session?.translator?.handle(event) this.deps.onEvent?.(event) } diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index c486923dbc9..590e175a6d3 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -7,6 +7,12 @@ export function settleClaudeDispatchWaiters(session: ClaudeSession): void { } } +export function settleClaudeExitedSession(session: ClaudeSession): void { + settleClaudeDispatchWaiters(session) + session.prompts.clear() + session.translator?.dispose() +} + export async function closeClaudePublishedSession(input: { sessions: Map sessionId: string @@ -53,11 +59,14 @@ export async function closeClaudePublishedSession(input: { } catch (error) { persistenceError = error } finally { - input.onEvent?.({ + const ended = { type: 'ended', sessionId: input.sessionId, reason: 'claude session closed' - }) + } as const + session.translator?.handle(ended) + input.onEvent?.(ended) + session.translator?.dispose() await session.connection.close() } if (persistenceError) { diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 521a08dd839..9b1567c0380 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -2,6 +2,7 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/ import { readClaudeFrameString, type ClaudeInitObservation } from './claude-structured-init-proof' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeSession } from './claude-structured-session-state' export function createClaudeSessionPublication(input: { @@ -11,6 +12,7 @@ export function createClaudeSessionPublication(input: { fence: number resumed: boolean prompts: ClaudePromptRegistry + translator: ClaudeJournalTranslator | null events: ClaudeSession['events'] process: AgentSessionAcquisition['process'] linkId?: string @@ -42,6 +44,7 @@ export function createClaudeSessionPublication(input: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + translator: input.translator, events: input.events } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ad2277a8b76..a94a03917af 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,6 +5,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' export type ClaudeAuthDiagnostic = { @@ -63,6 +64,7 @@ export type ClaudeSession = { dispatchWaiters: ClaudeDispatchWaiter[] options: Map reportedOptions: { model?: string; effort?: string } + translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts new file mode 100644 index 00000000000..1cc3809847f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -0,0 +1,89 @@ +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +type RoutedAgent = 'claude' | 'codex' + +export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { + private readonly owners = new Map() + + constructor( + private readonly adapters: Record, + private readonly closeAdapters: () => Promise + ) {} + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { + const adapter = this.adapterForAgent(agent) + return adapter ? (adapter.supportsLocation?.(location) ?? false) : false + } + + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + + async acquire(input: Parameters[0]) { + const adapter = this.requireAgent(input.identity) + const acquired = await adapter.acquire(input) + this.owners.set(input.identity.sessionId, adapter) + return acquired + } + + async releaseAcquisition(input: { sessionId: string }): Promise { + const adapter = this.owners.get(input.sessionId) + if (adapter) { + await adapter.releaseAcquisition?.(input) + this.owners.delete(input.sessionId) + return + } + for (const candidate of Object.values(this.adapters)) { + await candidate.releaseAcquisition?.(input) + } + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + this.owner(input.sessionId).dispatch(input) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => + this.owner(input.sessionId).cancelTurn(input) + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.owner(input.sessionId).answerPrompt(input) + + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + this.owner(input.sessionId).setOption(input) + + readOptions = (input: { sessionId: string; fence: number }) => { + const reader = this.owner(input.sessionId).readOptions + if (!reader) { + throw new Error(`structured session ${input.sessionId} does not report options`) + } + return reader(input) + } + + historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => + this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + + async closeAll(): Promise { + this.owners.clear() + await this.closeAdapters() + } + + private owner(sessionId: string): StructuredAgentSessionAdapter { + const adapter = this.owners.get(sessionId) + if (!adapter) { + throw new Error(`no live structured adapter owns ${sessionId}`) + } + return adapter + } + + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { + const adapter = this.adapterForAgent(identity.agent) + if (!adapter) { + throw new Error(`structured sessions do not support ${identity.agent}`) + } + return adapter + } + + private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { + return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index eea191011c5..f40848f6004 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -34,6 +34,8 @@ export type AgentSessionDispatchOutcome = | { state: 'unknown'; reason: string } export type StructuredAgentSessionAdapter = { + /** Provider-aware capability check for hosts that route more than one adapter. */ + supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ supportsLocation?(location: AgentSessionExecutionLocation): boolean /** Makes the reservation real. Called once per reservation, with the spawn diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b3cd6d78f93..6975c8d6902 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -59,6 +59,7 @@ import { } 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' export type StructuredAgentSessionCaller = { /** Stable per-client identity; scopes the operation ledger and records who @@ -109,18 +110,14 @@ export class StructuredAgentSessionHost { } supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean { - return agent === 'codex' && (this.deps.adapter.supportsLocation?.(location) ?? false) + return providerRouting.adapterSupportsAgentSessionCreate(this.deps.adapter, location, agent) } - listSessionTabs(): { - sessionId: string - workspaceId: string - agent: 'codex' - }[] { + listSessionTabs(): providerRouting.StructuredAgentSessionTab[] { return [...this.sessions.entries()].map(([sessionId, session]) => ({ sessionId, workspaceId: session.params.location.workspaceId, - agent: 'codex' as const + agent: providerRouting.structuredAgentSessionTabAgent(session.params.agent) })) } @@ -128,7 +125,11 @@ export class StructuredAgentSessionHost { await restoreStructuredAgentSessionsOnRestart({ store: this.deps.store, journalRoot: this.deps.journalRoot, - records: this.deps.store.listRecords().filter((record) => record.provider === 'codex'), + records: this.deps.store + .listRecords() + .filter((record) => + providerRouting.adapterSupportsAgentSessionRecord(this.deps.adapter, record) + ), reconcile: this.reconcileLeases, operationId: () => `${Math.trunc(this.now()).toString().padStart(13, '0')}-${randomUUID().replaceAll('-', '')}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts new file mode 100644 index 00000000000..8025fb30075 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts @@ -0,0 +1,35 @@ +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: 'claude' | 'codex' +} + +export function adapterSupportsAgentSessionCreate( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + return ( + adapter.supportsCreate?.(location, agent) ?? + (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) + ) +} + +export function adapterSupportsAgentSessionRecord( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord +): boolean { + return adapter.supportsCreate + ? adapter.supportsCreate(record.location, record.provider) + : record.provider === 'codex' +} + +export function structuredAgentSessionTabAgent(agent: string): 'claude' | 'codex' { + return agent === 'claude' ? 'claude' : 'codex' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 82d3116c9ad..a5f19cc8e55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -25,7 +25,7 @@ export async function restoreStructuredAgentSessionRead( sessionId: string ): Promise { const record = store.getRecord(sessionId) - if (!record || record.provider !== 'codex') { + if (!record) { return null } const params = attachParamsForRecord(record, { @@ -59,8 +59,8 @@ export function attachParamsForRecord( payloadFingerprint: '' }, location: record.location, - provider: 'codex', - agent: 'codex', + provider: record.provider, + agent: record.provider, accountHome: record.accountHome, runtimeKind: record.lease.runtimeKind } diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts new file mode 100644 index 00000000000..f9fe77cbdc5 --- /dev/null +++ b/src/main/runtime/agent-session-claim-key-state.ts @@ -0,0 +1,46 @@ +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export function isVerifiable( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): boolean { + const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= retentionMs +} + +export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { + return { + ...record, + updatedAt: now, + // A conflicted key must remain conflicted after its observing process exits. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + } +} + +export function retire( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): void { + if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + state.retiredClaimKeys = state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= retentionMs + ) +} + +export function listOrphanSpawnTokens( + records: readonly AgentSessionRecord[], + observedTokens: readonly string[] +): string[] { + const leases = records.map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts new file mode 100644 index 00000000000..5904547936b --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +function resumedLink(fence: number): AgentSessionProviderHandleLink { + return { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'session-alpha-1', leafUuid: 'leaf-2' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: 4_000 + } +} + +describe('recordAgentSessionProviderHandle', () => { + it('advances a live Claude chain head and its proof', () => { + const record = agentSessionRecordFixture() + const next = recordAgentSessionProviderHandle({ + record, + fence: record.lease.runtimeFence, + link: resumedLink(record.lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease.provenHandleLinkId).toBe('link-2') + }) + + it('records a leaf during proof without granting ownership', () => { + const lease = agentSessionLeaseFixture({ + runtimeFence: 8, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + provenHandleLinkId: null + }) + const next = recordAgentSessionProviderHandle({ + record: agentSessionRecordFixture(lease), + fence: lease.runtimeFence, + link: resumedLink(lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) + }) +}) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts new file mode 100644 index 00000000000..474e4be7135 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -0,0 +1,41 @@ +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function recordAgentSessionProviderHandle(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number +}): AgentSessionRecord { + const { record } = args + if (record.lease.runtimeFence !== args.fence) { + throw new Error('agent_session_stale_fence') + } + if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_invalid') + } + if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { + throw new Error('agent_session_ownership_unknown') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + ...(record.lease.claimStatus === 'live' ? { provenHandleLinkId: head.linkId } : {}), + lastRenewedAt: args.now + }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 15fcef3c85e..32347c6d529 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -21,7 +21,6 @@ import { type AgentSessionOperationAdmission } from './agent-session-operation-admission' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { agentSessionScopeKey, @@ -41,6 +40,7 @@ import { setAgentSessionHandoffStage, setAgentSessionJournalCheckpoint } from './agent-session-lease-transitions' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' import { applyAgentSessionReservation, evaluateAgentSessionReserveOperation, @@ -58,6 +58,7 @@ import { AgentSessionStoreTransactionQueue, markAgentSessionStoreLeasesUnreconciled } from './agent-session-store-transaction-queue' +import * as claimKeyState from './agent-session-claim-key-state' export type { AgentSessionReserveRequest, @@ -131,16 +132,12 @@ export class AgentSessionRecordStore { } isClaimKeyVerifiable(keyId: string, now: number): boolean { - const retired = this.state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + return claimKeyState.isVerifiable(this.state, keyId, now, AGENT_SESSION_CLAIM_KEY_RETENTION_MS) } /** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */ listOrphanSpawnTokens(observedTokens: readonly string[]): string[] { - const leases = this.listRecords().map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) + return claimKeyState.listOrphanSpawnTokens(this.listRecords(), observedTokens) } /** @@ -200,6 +197,17 @@ export class AgentSessionRecordStore { ) } + async recordProviderHandle(args: { + sessionId: string + fence: number + link: AgentSessionProviderHandleLink + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => + recordAgentSessionProviderHandle({ ...args, record }) + ) + } + async renewLease(args: { sessionId: string fence: number @@ -307,24 +315,13 @@ export class AgentSessionRecordStore { } async markClaimConflicted(sessionId: string, now: number): Promise { - return this.mutate(sessionId, (record) => ({ - ...record, - updatedAt: now, - // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free - // merely because the process that observed the conflict is gone. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - })) + return this.mutate(sessionId, (record) => claimKeyState.markConflicted(record, now)) } async retireClaimKey(keyId: string, now: number): Promise { - await this.transact(() => { - if (!this.state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - this.state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - this.state.retiredClaimKeys = this.state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS - ) - }) + await this.transact(() => + claimKeyState.retire(this.state, keyId, now, AGENT_SESSION_CLAIM_KEY_RETENTION_MS) + ) } private async mutate( diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts new file mode 100644 index 00000000000..646f55fbbe7 --- /dev/null +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -0,0 +1,358 @@ +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 { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from '../claude/claude-stream-json-connection' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +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 { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'claude-integration-1' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const WORKSPACE = 'workspace-claude' +const CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +type FakeClaudeConnection = Omit & { + closed: boolean + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] + replies: { requestId: string; response: unknown }[] +} + +function fakeClaude() { + const connections: FakeClaudeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeClaudeConnection = { + launch, + handlers, + calls: [], + sent: [], + replies: [], + pid: 4321 + connections.length, + closed: false, + request: async (subtype, params) => { + connection.calls.push({ subtype, params }) + if (subtype === 'initialize') { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION, + ...(connections.length === 0 ? { uuid: 'init-leaf' } : {}), + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + return { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + } + return subtype === 'get_settings' ? { env: {} } : {} + }, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user') { + handlers.onMessage?.({ ...message, uuid: 'user-1' }) + } + }, + respond: async (requestId, response) => { + connection.replies.push({ requestId, response }) + }, + respondWithError: async () => {}, + close: async () => { + connection.closed = true + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + const live = (): FakeClaudeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no Claude connection') + } + return connection + } + return { connections, openConnection, live } +} + +let operations = 0 + +function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'claude' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +function ensureParams(fence: number) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'claude' as const, + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/accounts/claude' }, + runtimeKind: 'native' as const, + providerHandle: { + kind: 'claude' as const, + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + } + } + const base = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...base, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope: base } as never) + }) + } + } +} + +let claude: ReturnType +let root: string +let dispatcher: RpcDispatcher +let cleanups: Map void> + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, JSON.stringify(response)).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T } }).result + expect(result).toMatchObject({ ok: true }) + return result.value as T +} + +async function subscribe(): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: 'subscribe-1', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const rows = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.snapshot.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + } + } + return [...items.values()] +} + +function textOf(item: AgentJournalRenderItem): string { + return item.body?.kind === 'message' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) + claude = fakeClaude() + cleanups = new Map() + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ + ...ensureParams(1), + envelope: input.envelope, + providerHandle: undefined + }), + publishStructuredAgentSessionTab: vi.fn(), + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeCommand: () => '/usr/local/bin/claude', + openClaudeConnection: claude.openConnection + }).then(() => undefined), + registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), + cleanupSubscription: (id: string) => cleanups.get(id)?.(), + cleanupSubscriptionsByPrefix: () => {} + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured Claude session over agentSession.*', () => { + it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect(claude.live().launch.args).toContain('--session-id') + expect(claude.live().launch.args).toContain(PROVIDER_SESSION) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) + + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Two files.' } } + }) + claude.live().handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Two files.' }] } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + expect(itemsOf(stream).find((item) => textOf(item) === 'Two files.')?.itemId).toBe( + `claude:${PROVIDER_SESSION}:assistant-leaf` + ) + + claude.live().handlers.onControlRequest?.({ + type: 'control_request', + request_id: 'permission-1', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'tool-1', + input: { command: 'ls' } + } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' }) + await ok('agentSession.respondToApproval', { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }) + expect(claude.live().replies.at(-1)).toMatchObject({ + requestId: 'permission-1', + response: { behavior: 'allow', toolUseID: 'tool-1' } + }) + + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: 'user-1' }, created.fence), + turnId: 'user-1' + }) + ).resolves.toMatchObject({ turnId: 'user-1', cancelled: true }) + expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + + const old = claude.live() + const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) + expect(resumed.fence).toBe(created.fence + 1) + expect(old.closed).toBe(true) + expect(claude.live().launch.args.slice(-2)).toEqual(['--resume', PROVIDER_SESSION]) + const host = getStructuredAgentSessionHost() as unknown as { + deps: { store: { getRecord: (sessionId: string) => { providerHandleChain: unknown[] } } } + } + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + }, + origin: 'resumed' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 083c677e39f..b59092f018d 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -52,4 +52,56 @@ describe('structured agent-session create intent', () => { path: '/accounts/selected/home' }) }) + + it('pins the selected Claude config directory without changing its auth environment', async () => { + const runtime = new OrcaRuntimeService({ + getSettings: () => ({ + agentDefaultEnv: { + claude: { + CLAUDE_CONFIG_DIR: '/accounts/claude/home', + ANTHROPIC_AUTH_TOKEN: 'inherited-by-launch' + } + } + }) + } as never) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'folder' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/folders/one' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-claude', clientOperationId: 'operation-claude' }, + worktree: 'id:folder-1', + agent: 'claude' + }) + + expect(intent).toMatchObject({ + provider: 'claude', + agent: 'claude', + location: { workspaceKind: 'folder' }, + accountHome: { + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude/home' + } + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 246c4f33b20..219642e5ed8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9201,11 +9201,8 @@ export class OrcaRuntimeService { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'codex' + agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - if (agent !== 'codex') { - return { supported: false, reason: 'agent' } - } const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { @@ -9246,18 +9243,24 @@ export class OrcaRuntimeService { async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { throw new Error('structured_agent_session_unsupported') } const settings = this.requireStore().getSettings() - const launchEnv = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) + const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - const preparedHome = this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) - const configuredHome = launchEnv.CODEX_HOME + const preparedHome = + input.agent === 'codex' + ? this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + : null + const configuredHome = + input.agent === 'codex' ? launchEnv.CODEX_HOME : launchEnv.CLAUDE_CONFIG_DIR + const variable = + input.agent === 'codex' ? ('CODEX_HOME' as const) : ('CLAUDE_CONFIG_DIR' as const) return { envelope: { sessionId: input.envelope.sessionId, @@ -9266,16 +9269,18 @@ export class OrcaRuntimeService { payloadFingerprint: '' }, location, - provider: 'codex', - agent: 'codex', + provider: input.agent, + agent: input.agent, accountHome: { - variable: 'CODEX_HOME', + variable, path: - preparedHome?.trim() || - (this.prepareCodexStructuredLaunchFn - ? getSystemCodexHomePath() - : configuredHome?.trim()) || - getSystemCodexHomePath() + input.agent === 'codex' + ? preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn + ? getSystemCodexHomePath() + : configuredHome?.trim()) || + getSystemCodexHomePath() + : configuredHome?.trim() || join(homedir(), '.claude') }, runtimeKind: 'native' } @@ -9293,7 +9298,7 @@ export class OrcaRuntimeService { publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' activate: boolean notify?: boolean }): void { @@ -9305,7 +9310,7 @@ export class OrcaRuntimeService { const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: 'Codex Chat', + title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', sessionId: input.sessionId, agent: input.agent, isActive: input.activate diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 4288c5253c6..b052242cc97 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -98,7 +98,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 1f667db1953..2263b8af7b3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -137,7 +137,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, - agent: 'codex', + agent: resolved.agent === 'claude' ? 'claude' : 'codex', activate: true }) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 66968c4a6fc..9c708039ad2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -10,12 +10,19 @@ import { join } from 'node:path' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps +} from '../claude/claude-structured-session-adapter' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { CodexStructuredSessionAdapter, type CodexStructuredSessionAdapterDeps } 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 { 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' @@ -34,15 +41,17 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: () => string + resolveClaudeCommand?: () => string /** Transport for the Codex child. Overridden only to drive the whole runtime * against a scripted app-server; production spawns the real one. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] onError?: (input: { scope: string; error: unknown }) => void } type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: StructuredAgentSessionAdapterRouter } let installing: Promise | null = null @@ -58,7 +67,7 @@ export function ensureStructuredAgentSessionHost( return installing.then((installed) => installed.host) } -/** Drops the host and reaps every Codex child under it. Runtime teardown and +/** Drops the host and reaps every structured child under it. Runtime teardown and * test isolation take the same path, so neither can leave a live app-server. */ export async function stopStructuredAgentSessionRuntime(): Promise { const pending = installing @@ -83,7 +92,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + 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, + 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, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a94c7e36ac2..9e331046ac8 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -256,7 +256,7 @@ export type RuntimeMobileSessionAgentTab = { id: string title: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' color?: string | null isPinned?: boolean isActive: boolean