From f289fee89476c61a39c2e4aaaeb6bdad8d3b3915 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 14:50:27 -0700 Subject: [PATCH] feat(native-chat): add Claude structured adapter --- .../claude-child-process-environment.test.ts | 29 + .../claude-child-process-environment.ts | 47 ++ .../claude-stream-json-connection.test.ts | 197 ++++++ .../claude/claude-stream-json-connection.ts | 286 ++++++++ src/main/claude/claude-stream-json-stdout.ts | 80 +++ .../claude-structured-acquisition-events.ts | 79 +++ .../claude-structured-control-actions.ts | 34 + .../claude/claude-structured-dispatch.test.ts | 145 ++++ src/main/claude/claude-structured-dispatch.ts | 108 +++ .../claude-structured-inbound-control.test.ts | 105 +++ .../claude-structured-inbound-control.ts | 90 +++ .../claude/claude-structured-init-deadline.ts | 72 ++ .../claude/claude-structured-init-proof.ts | 74 ++ .../claude-structured-item-translation.ts | 158 +++++ ...ude-structured-journal-translation.test.ts | 349 +++++++++ .../claude-structured-journal-translation.ts | 301 ++++++++ ...laude-structured-launch-resolution.test.ts | 130 ++++ .../claude-structured-launch-resolution.ts | 104 +++ src/main/claude/claude-structured-options.ts | 57 ++ .../claude-structured-owner-identity.test.ts | 46 ++ .../claude-structured-owner-identity.ts | 36 + .../claude/claude-structured-prompt-items.ts | 114 +++ .../claude-structured-prompt-replies.ts | 233 ++++++ .../claude-structured-provider-events.ts | 43 ++ .../claude-structured-provider-fallback.ts | 52 ++ .../claude-structured-session-adapter.test.ts | 667 ++++++++++++++++++ .../claude-structured-session-adapter.ts | 272 +++++++ .../claude/claude-structured-session-close.ts | 79 +++ .../claude-structured-session-options.ts | 112 +++ .../claude-structured-session-publication.ts | 52 ++ .../claude/claude-structured-session-state.ts | 177 +++++ .../provider-frame-disposition.ts | 11 +- ...tured-agent-session-adapter-router.test.ts | 152 ++++ ...structured-agent-session-adapter-router.ts | 127 ++++ .../structured-agent-session-runtime.ts | 54 +- 35 files changed, 4665 insertions(+), 7 deletions(-) create mode 100644 src/main/claude/claude-child-process-environment.test.ts create mode 100644 src/main/claude/claude-child-process-environment.ts create mode 100644 src/main/claude/claude-stream-json-connection.test.ts create mode 100644 src/main/claude/claude-stream-json-connection.ts create mode 100644 src/main/claude/claude-stream-json-stdout.ts create mode 100644 src/main/claude/claude-structured-acquisition-events.ts create mode 100644 src/main/claude/claude-structured-control-actions.ts create mode 100644 src/main/claude/claude-structured-dispatch.test.ts create mode 100644 src/main/claude/claude-structured-dispatch.ts create mode 100644 src/main/claude/claude-structured-inbound-control.test.ts create mode 100644 src/main/claude/claude-structured-inbound-control.ts create mode 100644 src/main/claude/claude-structured-init-deadline.ts create mode 100644 src/main/claude/claude-structured-init-proof.ts 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-launch-resolution.test.ts create mode 100644 src/main/claude/claude-structured-launch-resolution.ts create mode 100644 src/main/claude/claude-structured-options.ts create mode 100644 src/main/claude/claude-structured-owner-identity.test.ts create mode 100644 src/main/claude/claude-structured-prompt-items.ts create mode 100644 src/main/claude/claude-structured-prompt-replies.ts create mode 100644 src/main/claude/claude-structured-provider-events.ts create mode 100644 src/main/claude/claude-structured-provider-fallback.ts create mode 100644 src/main/claude/claude-structured-session-adapter.test.ts create mode 100644 src/main/claude/claude-structured-session-adapter.ts create mode 100644 src/main/claude/claude-structured-session-close.ts create mode 100644 src/main/claude/claude-structured-session-options.ts create mode 100644 src/main/claude/claude-structured-session-publication.ts create mode 100644 src/main/claude/claude-structured-session-state.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts diff --git a/src/main/claude/claude-child-process-environment.test.ts b/src/main/claude/claude-child-process-environment.test.ts new file mode 100644 index 00000000000..9dc7007f07b --- /dev/null +++ b/src/main/claude/claude-child-process-environment.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' + +describe('Claude child process environment', () => { + it('strips case-insensitive inherited auth and session stamps on Windows', () => { + const env = buildClaudeChildProcessEnv( + { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session' + }, + { + platform: 'win32', + inheritedEnv: { + anthropic_api_key: 'inherited-key', + Anthropic_Custom_Headers: 'Authorization: inherited', + claude_code_child_session: '1', + CLAUDE_CODE_SESSION_ID: 'inherited-session', + SAFE_VALUE: 'preserved' + } + } + ) + + expect(env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session', + SAFE_VALUE: 'preserved' + }) + }) +}) diff --git a/src/main/claude/claude-child-process-environment.ts b/src/main/claude/claude-child-process-environment.ts new file mode 100644 index 00000000000..ad5e854d4ff --- /dev/null +++ b/src/main/claude/claude-child-process-environment.ts @@ -0,0 +1,47 @@ +import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment' + +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + +function cloneProcessEnv(source: NodeJS.ProcessEnv): Record { + const env: Record = {} + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + env[key] = value + } + } + return env +} + +export function buildClaudeChildProcessEnv( + configuredEnv: Record = {}, + options: { inheritedEnv?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {} +): Record { + const inheritedEnv = options.inheritedEnv ?? process.env + const platform = options.platform ?? process.platform + const env = applyClaudeEnvPatch(cloneProcessEnv(inheritedEnv), {}, { stripAuthEnv: true }) + if (platform === 'win32') { + const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase())) + for (const [key, value] of Object.entries(env)) { + const normalized = key.toUpperCase() + if ( + authKeys.has(normalized) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && + /authorization|x-api-key|api-key|bearer/i.test(value)) + ) { + delete env[key] + } + } + } + for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) { + for (const inheritedKey of Object.keys(env)) { + if (inheritedKey === key || (platform === 'win32' && inheritedKey.toUpperCase() === key)) { + delete env[inheritedKey] + } + } + } + return Object.assign(env, configuredEnv) +} diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts new file mode 100644 index 00000000000..a410de4f51e --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -0,0 +1,197 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { + openClaudeStreamJsonConnection, + type ClaudeControlRequest +} from './claude-stream-json-connection' + +type FakeChild = EventEmitter & { + pid: number + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType +} + +function fakeSpawn() { + const child = new EventEmitter() as FakeChild + child.pid = 4321 + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn(() => true) + const spawnMock = vi.fn((_spec: Parameters[0]) => child) + const spawnImpl = spawnMock as unknown as typeof spawnProcess + return { child, spawnImpl, spawnMock } +} + +function writtenFrames(child: FakeChild): Promise[]> { + return new Promise((resolve) => { + setImmediate(() => { + const text = child.stdin.read()?.toString('utf8') ?? '' + resolve( + text + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record) + ) + }) + }) +} + +describe('Claude stream-json connection', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('spawns in the pinned workspace and routes acknowledged control requests', async () => { + const process = fakeSpawn() + const connection = await openClaudeStreamJsonConnection( + { + command: 'claude', + args: ['-p'], + cwd: '/work/repo', + env: { CLAUDE_CONFIG_DIR: '/accounts/one' } + }, + {}, + process.spawnImpl + ) + const listing = connection.request('list_models') + const outbound = await writtenFrames(process.child) + const requestId = (outbound[0] as { request_id: string }).request_id + process.child.stdout.write( + `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { models: [{ value: 'sonnet' }] } + } + })}\n` + ) + + await expect(listing).resolves.toEqual({ models: [{ value: 'sonnet' }] }) + expect(process.spawnImpl).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'claude', + args: ['-p'], + cwd: '/work/repo', + env: expect.objectContaining({ CLAUDE_CONFIG_DIR: '/accounts/one' }) + }) + ) + }) + + it('routes provider permission controls and writes their response envelope', async () => { + const process = fakeSpawn() + let inbound: ClaudeControlRequest | null = null + const connection = await openClaudeStreamJsonConnection( + { command: 'claude', args: [], cwd: '/work' }, + { onControlRequest: (request) => (inbound = request) }, + process.spawnImpl + ) + process.child.stdout.write( + `${JSON.stringify({ + type: 'control_request', + request_id: 'permission-1', + request: { subtype: 'can_use_tool', tool_name: 'Bash' } + })}\n` + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(inbound).toMatchObject({ request_id: 'permission-1' }) + + await connection.respond('permission-1', { behavior: 'deny', message: 'No' }) + expect(await writtenFrames(process.child)).toEqual([ + { + type: 'control_response', + response: { + subtype: 'success', + request_id: 'permission-1', + response: { behavior: 'deny', message: 'No' } + } + } + ]) + }) + + it('forwards unmatched control responses to the provider-frame path', async () => { + const process = fakeSpawn() + const onMessage = vi.fn() + await openClaudeStreamJsonConnection( + { command: 'claude', args: [], cwd: '/work' }, + { onMessage }, + process.spawnImpl + ) + const frame = { + type: 'control_response', + response: { subtype: 'success', request_id: 'provider-owned', response: { opaque: true } } + } + + process.child.stdout.write(`${JSON.stringify(frame)}\n`) + await new Promise((resolve) => setImmediate(resolve)) + + expect(onMessage).toHaveBeenCalledWith(frame) + }) + + it('fails closed on malformed provider output', async () => { + const process = fakeSpawn() + const onExit = vi.fn() + const onMessage = vi.fn() + await openClaudeStreamJsonConnection( + { command: 'claude', args: [], cwd: '/work' }, + { onExit, onMessage }, + process.spawnImpl + ) + process.child.stdout.write('{not-json}\n{"type":"assistant"}\n') + await new Promise((resolve) => setImmediate(resolve)) + + expect(onMessage).not.toHaveBeenCalled() + expect(onExit).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })) + }) + + it('uses configured auth and strips inherited auth and child-session stamps', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'inherited-key') + vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1') + vi.stubEnv('CLAUDE_CODE_SESSION_ID', 'inherited-session') + const process = fakeSpawn() + + await openClaudeStreamJsonConnection( + { + command: 'claude', + args: [], + cwd: '/work', + env: { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CODE_SESSION_ID: 'configured-session' + } + }, + {}, + process.spawnImpl + ) + + const env = process.spawnMock.mock.calls[0]?.[0]?.env + expect(env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CODE_SESSION_ID: 'configured-session' + }) + expect(env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(env?.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + }) + + it('settles concurrent and repeated closes after a spawn failure', async () => { + const process = fakeSpawn() + const connection = await openClaudeStreamJsonConnection( + { command: 'missing-claude', args: [], cwd: '/work' }, + {}, + process.spawnImpl + ) + process.child.emit('error', new Error('spawn missing-claude ENOENT')) + + await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ + true, + true + ]) + await expect(connection.close()).resolves.toBe(true) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts new file mode 100644 index 00000000000..8c8aae76448 --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.ts @@ -0,0 +1,286 @@ +import { forceTerminateProcessTree } from '../../shared/child-process/process-tree-termination' +import { spawnProcess } from '../../shared/child-process/run-process' +import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import { attachClaudeStreamJsonStdout } from './claude-stream-json-stdout' + +export type ClaudeStreamJsonLaunch = { + command: string + args: string[] + cwd: string + env?: Record +} + +export type ClaudeControlRequest = { + type: 'control_request' + request_id: string + request: Record & { subtype: string } +} + +export type ClaudeControlCancelRequest = { + type: 'control_cancel_request' + request_id: string +} + +export type ClaudeControlResponder = Pick< + ClaudeStreamJsonConnection, + 'respond' | 'respondWithError' +> + +export type ClaudeStreamJsonConnectionHandlers = { + onMessage?: (message: Record) => void + onControlRequest?: (request: ClaudeControlRequest, responder?: ClaudeControlResponder) => void + onControlCancelRequest?: (request: ClaudeControlCancelRequest) => void + onExit?: (error: Error) => void +} + +export type ClaudeStreamJsonConnection = { + readonly pid: number | undefined + readonly closed: boolean + send: (message: Record) => Promise + request: ( + subtype: string, + params?: Record, + options?: { timeoutMs?: number } + ) => Promise + respond: (requestId: string, response: unknown) => Promise + respondWithError: (requestId: string, error: string) => Promise + close: () => Promise +} + +export class ClaudeControlRequestError extends Error { + constructor( + readonly subtype: string, + message: string + ) { + super(message) + this.name = 'ClaudeControlRequestError' + } +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 +const STDERR_TAIL_MAX_BYTES = 8192 +const STDOUT_LINE_MAX_BYTES = 8 * 1024 * 1024 + +type PendingRequest = { + subtype: string + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function exitError(stderrTail: string, cause?: Error): Error { + const detail = stderrTail.trim() + const message = detail ? `claude stream-json exited: ${detail}` : 'claude stream-json exited' + return cause ? new Error(message, { cause }) : new Error(message) +} +export async function openClaudeStreamJsonConnection( + launch: ClaudeStreamJsonLaunch, + handlers: ClaudeStreamJsonConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess +): Promise { + const child = spawnImpl({ + program: launch.command, + args: launch.args, + cwd: launch.cwd, + env: buildClaudeChildProcessEnv(launch.env), + stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32' + }) + const pending = new Map() + let nextRequestId = 1 + let stderrTail = '' + let exited = false + let closing = false + let terminalError: Error | null = null + let writeChain: Promise = Promise.resolve() + let closePromise: Promise | null = null + + let settleExit = (): void => {} + const exitPromise = new Promise((resolve) => { + settleExit = resolve + }) + const markExited = (): void => { + exited = true + settleExit() + } + child.on('exit', markExited) + + const failPending = (error: Error): void => { + for (const waiter of pending.values()) { + clearTimeout(waiter.timer) + waiter.reject(error) + } + pending.clear() + } + + const handleUnexpectedEnd = (cause?: Error): void => { + if (terminalError) { + return + } + terminalError = exitError(stderrTail, cause) + failPending(terminalError) + if (!closing) { + handlers.onExit?.(terminalError) + } + } + + const writeLine = (payload: Record): Promise => { + const write = async (): Promise => { + if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + throw terminalError ?? new Error('claude stream-json connection is closed') + } + const line = `${JSON.stringify(payload)}\n` + await new Promise((resolve, reject) => { + child.stdin.write(line, (error) => (error ? reject(error) : resolve())) + }) + } + const queued = writeChain.then(write) + writeChain = queued.catch(() => {}) + return queued + } + + const respond = (requestId: string, response: unknown): Promise => + writeLine({ + type: 'control_response', + response: { subtype: 'success', request_id: requestId, response } + }) + + const respondWithError = (requestId: string, error: string): Promise => + writeLine({ + type: 'control_response', + response: { subtype: 'error', request_id: requestId, error } + }) + + const dispatchMessage = (message: Record): void => { + if (message.type === 'control_response') { + const response = isRecord(message.response) ? message.response : null + const requestId = typeof response?.request_id === 'string' ? response.request_id : null + const waiter = requestId ? pending.get(requestId) : undefined + if (!waiter || !requestId || !response) { + handlers.onMessage?.(message) + return + } + pending.delete(requestId) + clearTimeout(waiter.timer) + if (response.subtype === 'success') { + waiter.resolve(response.response) + } else { + waiter.reject( + new ClaudeControlRequestError( + waiter.subtype, + typeof response.error === 'string' + ? response.error + : `claude ${waiter.subtype} request failed` + ) + ) + } + return + } + if ( + message.type === 'control_request' && + typeof message.request_id === 'string' && + isRecord(message.request) && + typeof message.request.subtype === 'string' + ) { + handlers.onControlRequest?.(message as ClaudeControlRequest, { respond, respondWithError }) + return + } + if (message.type === 'control_cancel_request' && typeof message.request_id === 'string') { + handlers.onControlCancelRequest?.(message as ClaudeControlCancelRequest) + return + } + handlers.onMessage?.(message) + } + const stdout = attachClaudeStreamJsonStdout({ + stdout: child.stdout, + maxLineBytes: STDOUT_LINE_MAX_BYTES, + onMessage: dispatchMessage, + onFailure: (error) => { + void forceTerminateProcessTree(child) + handleUnexpectedEnd(error) + } + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + child.on('error', (error) => { + markExited() + handleUnexpectedEnd(error) + }) + child.on('close', () => { + markExited() + stdout.flush() + handleUnexpectedEnd() + }) + child.stdin.on('error', (error) => { + if (!closing) { + void forceTerminateProcessTree(child) + handleUnexpectedEnd(error) + } + }) + + const request = ( + subtype: string, + params: Record = {}, + options: { timeoutMs?: number } = {} + ): Promise => { + const requestId = `orca-${nextRequestId++}` + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId) + reject(new Error(`claude ${subtype} request timed out`)) + }, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS) + timer.unref?.() + pending.set(requestId, { subtype, resolve, reject, timer }) + }) + void writeLine({ + type: 'control_request', + request_id: requestId, + request: { subtype, ...params } + }).catch((error) => { + const waiter = pending.get(requestId) + if (waiter) { + pending.delete(requestId) + clearTimeout(waiter.timer) + waiter.reject(error as Error) + } + }) + return promise + } + + const close = (): Promise => { + closePromise ??= (async () => { + closing = true + try { + child.stdin.end() + } catch { + // The reap below still owns the process. + } + await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS) + const descendantsExited = await forceTerminateProcessTree(child) + await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) + failPending(new Error('claude stream-json connection closed')) + return descendantsExited && exited + })() + return closePromise + } + + return { + get pid() { + return child.pid + }, + get closed() { + return closing || exited || terminalError !== null + }, + send: writeLine, + request, + respond, + respondWithError, + close + } +} diff --git a/src/main/claude/claude-stream-json-stdout.ts b/src/main/claude/claude-stream-json-stdout.ts new file mode 100644 index 00000000000..1d15fa1bb28 --- /dev/null +++ b/src/main/claude/claude-stream-json-stdout.ts @@ -0,0 +1,80 @@ +import type { Readable } from 'node:stream' + +export type ClaudeStreamJsonStdout = { + flush: () => void +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function attachClaudeStreamJsonStdout(input: { + stdout: Readable + maxLineBytes: number + onMessage: (message: Record) => void + onFailure: (error: Error) => void +}): ClaudeStreamJsonStdout { + let buffer = '' + let stopped = false + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + buffer = '' + input.stdout.removeListener('data', onData) + input.stdout.pause() + } + const fail = (error: Error): void => { + stop() + input.onFailure(error) + } + const parseLine = (line: string): void => { + if (!line.trim()) { + return + } + try { + const parsed: unknown = JSON.parse(line) + if (isRecord(parsed)) { + input.onMessage(parsed) + } + } catch (error) { + fail(new Error('claude emitted invalid stream-json', { cause: error })) + } + } + const onData = (chunk: string): void => { + if (stopped) { + return + } + buffer += chunk + let newline = buffer.indexOf('\n') + while (newline !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + if (Buffer.byteLength(line, 'utf8') > input.maxLineBytes) { + fail(new Error('claude emitted an oversized stream-json line')) + return + } + parseLine(line) + if (stopped) { + return + } + newline = buffer.indexOf('\n') + } + if (Buffer.byteLength(buffer, 'utf8') > input.maxLineBytes) { + fail(new Error('claude emitted an oversized stream-json line')) + } + } + + input.stdout.setEncoding('utf8').on('data', onData) + return { + flush: () => { + if (!stopped && buffer.trim()) { + const line = buffer + buffer = '' + parseLine(line) + } + } + } +} diff --git a/src/main/claude/claude-structured-acquisition-events.ts b/src/main/claude/claude-structured-acquisition-events.ts new file mode 100644 index 00000000000..c74df2ab367 --- /dev/null +++ b/src/main/claude/claude-structured-acquisition-events.ts @@ -0,0 +1,79 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeControlRequest, ClaudeControlResponder } from './claude-stream-json-connection' +import { resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { + handleClaudeInboundControl, + handleClaudeInboundControlCancel +} from './claude-structured-inbound-control' +import type { ClaudeInitDeadline } from './claude-structured-init-deadline' +import { readClaudeInit } from './claude-structured-init-proof' +import type { ClaudeStructuredProviderEvents } from './claude-structured-provider-events' +import { + observeClaudeTopLevelLeaf, + type ClaudeAcquisitionAttempt, + type ClaudeSession, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +export class ClaudeStructuredAcquisitionEvents { + private session: ClaudeSession | null = null + private leafUuid: string | null = null + + constructor( + private readonly sessionId: string, + private readonly attempt: ClaudeAcquisitionAttempt, + private readonly sink: StructuredAgentSessionEventSink | undefined, + private readonly initDeadline: ClaudeInitDeadline, + private readonly providerEvents: ClaudeStructuredProviderEvents + ) {} + + observeResumeLeaf(leafUuid: string | null): void { + this.leafUuid = leafUuid + } + + publish(session: ClaudeSession): void { + this.session = session + } + + observedLeaf(): string | null { + return this.leafUuid + } + + emit(event: ClaudeStructuredSessionEvent): void { + this.providerEvents.deliver(this.attempt, this.sessionId, () => + this.providerEvents.emit(this.session, this.sink, event) + ) + } + + onMessage = (message: Record): void => { + const init = readClaudeInit(message) + if (init) { + this.initDeadline.resolve(init) + } + this.leafUuid = observeClaudeTopLevelLeaf(this.leafUuid, message) + if (this.session) { + this.session.leafUuid = this.leafUuid + resolveClaudeReplayWaiter(this.session, message) + } + this.emit({ type: 'message', sessionId: this.sessionId, message }) + } + + onControlRequest = (request: ClaudeControlRequest, responder?: ClaudeControlResponder): void => { + handleClaudeInboundControl({ + sessionId: this.sessionId, + attempt: this.attempt, + request, + responder, + emit: (event) => this.emit(event) + }) + } + + onControlCancelRequest = ({ request_id: requestId }: { request_id: string }): void => { + handleClaudeInboundControlCancel({ + sessionId: this.sessionId, + attempt: this.attempt, + requestId, + emit: (event) => this.emit(event) + }) + } +} diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts new file mode 100644 index 00000000000..f3e97ed284e --- /dev/null +++ b/src/main/claude/claude-structured-control-actions.ts @@ -0,0 +1,34 @@ +import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import type { ClaudeSession } from './claude-structured-session-state' + +export async function cancelClaudeTurn( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise<{ cancelled: boolean }> { + try { + await session.connection.request('interrupt', {}, { timeoutMs }) + return { cancelled: true } + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + return { cancelled: false } + } + throw error + } +} + +export async function answerClaudePrompt( + session: ClaudeSession, + input: { itemId: string; kind: 'approval' | 'question'; optionId: string } +): Promise { + const found = session.prompts.find(input.itemId) + if (!found || found.prompt.kind !== input.kind) { + throw new Error(`claude is no longer waiting on ${input.itemId}`) + } + const response = applyClaudePromptAnswer(found, input.optionId) + if (response === null) { + return + } + session.prompts.forget(found.prompt) + await session.connection.respond(found.prompt.requestId, response) +} diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts new file mode 100644 index 00000000000..50efdcba615 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession { + return { + connection: { send } as unknown as ClaudeSession['connection'], + providerSessionId: 'provider-session', + leafUuid: null, + fence: 1, + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + options: new Map(), + reportedOptions: {}, + events: undefined, + translator: null + } +} + +function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('Claude structured text dispatch', () => { + it('accepts a slash command from its result receipt when Claude omits the user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'command-result-uuid' + } + }) + }) + + it('does not mistake a normal turn result for its missing user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'result', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + expect(session.dispatchWaiters).toHaveLength(1) + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid' + }) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'user-replay-uuid' } + }) + }) + + it('leaves image dispatch explicitly unavailable for slice 2', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude structured image dispatch is not available yet' + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude structured image dispatch is not available yet' + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude structured image dispatch is not available yet' + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts new file mode 100644 index 00000000000..08080697477 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.ts @@ -0,0 +1,108 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeSession } from './claude-structured-session-state' +import { readClaudeFrameString } from './claude-structured-init-proof' + +export function resolveClaudeReplayWaiter( + session: ClaudeSession, + message: Record +): void { + const isUserReplay = message.type === 'user' && message.parent_tool_use_id === null + const isCompletedCommand = message.type === 'result' + if ( + (!isUserReplay && !isCompletedCommand) || + readClaudeFrameString(message, 'session_id') !== session.providerSessionId + ) { + return + } + const uuid = readClaudeFrameString(message, 'uuid') + const current = session.dispatchWaiters[0] + if (isCompletedCommand && !current?.acceptsResult) { + return + } + const waiter = uuid ? session.dispatchWaiters.shift() : undefined + if (waiter && uuid) { + clearTimeout(waiter.timer) + waiter.resolve(uuid) + } +} + +async function messageContent(body: AgentJournalMessageItem): Promise { + if (body.role !== 'user') { + throw new Error('Claude dispatch accepts only user messages') + } + const content: unknown[] = [] + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + content.push({ type: 'text', text: block.text }) + } else if (block.type === 'image-ref') { + throw new Error('Claude structured image dispatch is not available yet') + } + } + if (content.length === 0) { + throw new Error('Claude dispatch requires text') + } + return content +} + +function waitForReplay( + session: ClaudeSession, + timeoutMs: number, + acceptsResult: boolean +): Promise { + return new Promise((resolve) => { + const waiter = { + acceptsResult, + resolve, + timer: setTimeout(() => { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + resolve(null) + }, timeoutMs) + } + waiter.timer.unref?.() + session.dispatchWaiters.push(waiter) + }) +} + +export async function dispatchClaudeTurn( + session: ClaudeSession, + input: { clientMessageId: string; body: AgentJournalMessageItem }, + timeoutMs: number +): Promise { + let content: unknown[] + try { + content = await messageContent(input.body) + } catch (error) { + return { state: 'rejected', reason: (error as Error).message } + } + const acceptsResult = input.body.blocks.some( + (block) => block.type === 'text' && block.text.trimStart().startsWith('/') + ) + const replayed = waitForReplay(session, timeoutMs, acceptsResult) + try { + await session.connection.send({ + type: 'user', + message: { role: 'user', content }, + parent_tool_use_id: null, + session_id: session.providerSessionId + }) + } catch (error) { + const waiter = session.dispatchWaiters.shift() + if (waiter) { + clearTimeout(waiter.timer) + waiter.resolve(null) + } + return { state: 'unknown', reason: (error as Error).message } + } + const uuid = await replayed + return uuid + ? { + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + } + : { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' } +} diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts new file mode 100644 index 00000000000..42ac95751db --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { createClaudeAcquisitionAttempt } from './claude-structured-session-state' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { + CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES, + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE, + handleClaudeInboundControl +} from './claude-structured-inbound-control' + +function rejectingAttempt() { + const attempt = createClaudeAcquisitionAttempt(new ClaudePromptRegistry()) + const respond = vi.fn().mockRejectedValue(new Error('connection closed')) + const respondWithError = vi.fn().mockRejectedValue(new Error('connection closed')) + attempt.connection = { respond, respondWithError } as never + return { attempt, respond, respondWithError } +} + +describe('Claude inbound control', () => { + it('routes a valid permission request to the durable prompt registry', () => { + const control = rejectingAttempt() + const emit = vi.fn() + + expect( + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: control.attempt, + request: { + type: 'control_request', + request_id: 'permission-1', + request: { + subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE, + tool_use_id: 'tool-1', + tool_name: 'Bash', + input: { command: 'git status' } + } + }, + emit + }) + ).toEqual({ kind: 'prompt' }) + expect(control.respond).not.toHaveBeenCalled() + expect(control.respondWithError).not.toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'prompt' })) + }) + + it('denies a malformed permission request with a protocol response', () => { + const control = rejectingAttempt() + + expect( + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: control.attempt, + request: { + type: 'control_request', + request_id: 'permission-2', + request: { subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE, tool_use_id: 'tool-2' } + }, + emit: vi.fn() + }) + ).toEqual({ kind: 'responded', subtype: CLAUDE_CAN_USE_TOOL_SUBTYPE }) + expect(control.respond).toHaveBeenCalledWith('permission-2', { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + toolUseID: 'tool-2' + }) + }) + + it('consumes rejected writes while declining unsupported controls', async () => { + const dialog = rejectingAttempt() + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: dialog.attempt, + request: { + type: 'control_request', + request_id: 'dialog-1', + request: { subtype: 'request_user_dialog' } + }, + emit: vi.fn() + }) + const unsupported = rejectingAttempt() + handleClaudeInboundControl({ + sessionId: 'session-1', + attempt: unsupported.attempt, + request: { + type: 'control_request', + request_id: 'control-1', + request: { subtype: 'future_control' } + }, + emit: vi.fn() + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(dialog.respond).toHaveBeenCalledWith('dialog-1', { behavior: 'cancelled' }) + expect(unsupported.respondWithError).toHaveBeenCalledWith( + 'control-1', + 'Orca does not handle future_control' + ) + }) + + it('enumerates every blocking control request in the stable stream-json surface', () => { + expect(new Set(CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES)).toEqual( + new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]) + ) + }) +}) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts new file mode 100644 index 00000000000..4bde609a0cd --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -0,0 +1,90 @@ +import type { ClaudeControlRequest, ClaudeControlResponder } from './claude-stream-json-connection' +import type { + ClaudeAcquisitionAttempt, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' +export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' +export const CLAUDE_BLOCKING_CONTROL_REQUEST_SUBTYPES = [ + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE +] as const + +export type ClaudeInboundControlDisposition = + | { kind: 'prompt' } + | { kind: 'responded'; subtype: string } + +export function handleClaudeInboundControlCancel(input: { + sessionId: string + attempt: ClaudeAcquisitionAttempt + requestId: string + emit: (event: ClaudeStructuredSessionEvent) => void +}): void { + const prompt = input.attempt.prompts.cancel(input.requestId) + input.emit( + prompt + ? { + type: 'prompt-cancelled', + sessionId: input.sessionId, + promptKey: prompt.promptKey + } + : { + type: 'provider-frame', + sessionId: input.sessionId, + kind: 'control_cancel_request', + payload: { request_id: input.requestId } + } + ) +} + +/** Every Claude control request becomes a durable prompt or receives a safe reply. */ +export function handleClaudeInboundControl(input: { + sessionId: string + attempt: ClaudeAcquisitionAttempt + request: ClaudeControlRequest + responder?: ClaudeControlResponder + emit: (event: ClaudeStructuredSessionEvent) => void +}): ClaudeInboundControlDisposition { + const responder = input.responder ?? input.attempt.connection + const subtype = input.request.request.subtype + if (subtype === CLAUDE_REQUEST_USER_DIALOG_SUBTYPE) { + void responder?.respond(input.request.request_id, { behavior: 'cancelled' }).catch(() => {}) + input.emit({ + type: 'provider-frame', + sessionId: input.sessionId, + kind: `control_request:${subtype}`, + payload: input.request.request + }) + return { kind: 'responded', subtype } + } + const prompt = input.attempt.prompts.register(input.request) + if (prompt) { + input.emit({ type: 'prompt', sessionId: input.sessionId, prompt }) + return { kind: 'prompt' } + } + if (subtype === CLAUDE_CAN_USE_TOOL_SUBTYPE) { + const toolUseId = + typeof input.request.request.tool_use_id === 'string' + ? input.request.request.tool_use_id + : undefined + void responder + ?.respond(input.request.request_id, { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + ...(toolUseId ? { toolUseID: toolUseId } : {}) + }) + .catch(() => {}) + } else { + void responder + ?.respondWithError(input.request.request_id, `Orca does not handle ${subtype}`) + .catch(() => {}) + } + input.emit({ + type: 'provider-frame', + sessionId: input.sessionId, + kind: `control_request:${subtype}`, + payload: input.request.request + }) + return { kind: 'responded', subtype } +} diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts new file mode 100644 index 00000000000..9dfa66a8ffe --- /dev/null +++ b/src/main/claude/claude-structured-init-deadline.ts @@ -0,0 +1,72 @@ +import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeInitializationAuthError } from './claude-structured-init-proof' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitDeadline = { + promise: Promise + resolve: (init: ClaudeInitObservation) => void + reject: (error: Error) => void + start: () => void + clear: () => void +} + +export function claudeInitTimeoutError( + sessionId: string, + timeoutMs: number +): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal( + `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.` + ) +} + +export async function requestClaudeInitialization( + connection: ClaudeStreamJsonConnection, + sessionId: string, + timeoutMs: number +): Promise { + try { + const result = await connection.request( + 'initialize', + { supportedDialogKinds: [] }, + { timeoutMs } + ) + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + } catch (error) { + if (error instanceof Error && error.message === 'claude initialize request timed out') { + throw claudeInitTimeoutError(sessionId, timeoutMs) + } + throw error + } +} + +export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline { + let resolve = (_init: ClaudeInitObservation): void => {} + let reject = (_error: Error): void => {} + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + void promise.catch(() => {}) + let timer: ReturnType | null = null + + return { + promise, + resolve, + reject, + start: () => { + timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs) + timer.unref?.() + }, + clear: () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts new file mode 100644 index 00000000000..ca9a355264d --- /dev/null +++ b/src/main/claude/claude-structured-init-proof.ts @@ -0,0 +1,74 @@ +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitObservation = { + providerSessionId: string + uuid: string | null + message: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeFrameString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeInit(message: Record): ClaudeInitObservation | null { + const hookName = readClaudeFrameString(message, 'hook_name') + const isInit = message.type === 'system' && message.subtype === 'init' + const isSessionStart = + message.type === 'system' && + (message.subtype === 'hook_started' || message.subtype === 'hook_response') && + hookName?.startsWith('SessionStart:') === true + if (!isInit && !isSessionStart) { + return null + } + const providerSessionId = readClaudeFrameString(message, 'session_id') + return providerSessionId + ? { + providerSessionId, + uuid: isInit ? readClaudeFrameString(message, 'uuid') : null, + message + } + : null +} + +export function readClaudeModels(initialization: unknown): unknown[] { + return isRecord(initialization) && Array.isArray(initialization.models) + ? initialization.models + : [] +} + +export function claudeInitializationAuthError( + initialization: unknown +): AgentSessionAcquisitionRefusal | null { + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null + return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' + ? new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + ) + : null +} + +export function claudeAuthDiagnostic( + init: ClaudeInitObservation, + settings: unknown +): ClaudeAuthDiagnostic { + const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} + const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const configured = (key: string): boolean => + (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || + Boolean(process.env[key]?.trim()) + return { + apiKeySourceConfigured: apiKeySource !== null && apiKeySource !== 'none', + baseUrlConfigured: configured('ANTHROPIC_BASE_URL'), + authTokenConfigured: configured('ANTHROPIC_AUTH_TOKEN'), + apiKeyConfigured: configured('ANTHROPIC_API_KEY'), + settingSources: CLAUDE_DEFAULT_SETTING_SOURCES + } +} 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..f91591c104b --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -0,0 +1,349 @@ +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 { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +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('bounds persisted thinking text to the shared journal payload limit', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const thinking = 'considering '.repeat(20_000) + + translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + }) + + it('starts a cancellable lifecycle for image-only root user replays', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'user-image', [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } } + ]) + ) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId: 'user-image', state: 'running' } + }) + }) + + it('renders empty user frames through the provider fallback', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'control-only', [])) + + expect(state.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'message:user:empty' }) + }) + }) + ]) + ) + }) + + it('renders every unmodeled Claude frame family as a bounded provider row', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'compact_boundary', summary: 'x'.repeat(100_000) } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'command_started', command: '/compact' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'prompt_suggestion', suggestion: '/compact' } + }) + translator.handle( + message('user', 'attachment-1', [ + { type: 'document', source: { type: 'base64', media_type: 'application/pdf' } } + ]) + ) + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:future_control', + payload: { subtype: 'future_control' } + }) + + const frames = state.items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : [] + ) + expect(frames.map((frame) => frame.kind)).toEqual([ + 'message:system:command_started', + 'message:result', + 'message:user:content:document', + 'control_request:future_control' + ]) + }) + + it('preserves grouped questions as addressable items 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(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + question: 'Ship?' + }) + expect(bindings.at(-1)).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Aquestions-1%3Aq2', + 'questions-1' + ]) + + const multiSelect = prompt({ + requestId: 'questions-multi', + promptKey: 'questions-multi', + toolUseId: 'tool-multi', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }] + } + ] + }, + questionIds: ['Libraries?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + question: 'Libraries?', + options: [{ label: 'Luxon' }, { label: 'Temporal' }], + freeTextQuestionId: 'q1' + }) + + 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..c837e008af9 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -0,0 +1,301 @@ +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 { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +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' +import { + claudeProviderFrameKind, + createClaudeProviderFrameFallback, + isModeledClaudeContent +} from './claude-structured-provider-fallback' + +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 providerFallback = createClaudeProviderFrameFallback(deps.sink) + + 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): boolean => { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return false + } + 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: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + changed = true + } + const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part)) + for (const part of unhandledContent) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + providerFallback.append(`message:${envelope.role}:content:${partType}`, part) + changed = true + } + if (envelope.content.length === 0) { + providerFallback.append(`message:${envelope.role}:empty`, message) + changed = true + } + if ( + envelope.role === 'user' && + envelope.content.length > 0 && + 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() + } + return true + } + + 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) + } + } 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 + } + providerFallback.append(claudeProviderFrameKind(event.message), event.message) + } else if (event.type === 'message') { + if (!handleMessage(event.message)) { + providerFallback.append(claudeProviderFrameKind(event.message), event.message) + } + } else if (event.type === 'provider-frame') { + providerFallback.append(event.kind, event.payload) + } + }, + flush: flushStreams, + dispose: () => { + coalescer.dispose() + tools.clear() + promptItems.clear() + streamIdentities.clear() + latestStreamText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts new file mode 100644 index 00000000000..b4908f7685a --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { + CLAUDE_DEFAULT_SETTING_SOURCES, + CLAUDE_STRUCTURED_BASE_ARGS, + claudeSessionIdForOrcaSession, + createClaudeStructuredLaunchResolver +} from './claude-structured-launch-resolution' + +const SESSION_ID = 'orca-session-1' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [], + ...overrides + } as AgentSessionRecord +} + +function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + ...(resolveEnv ? { resolveEnvironment: async () => resolveEnv() } : {}) + }) +} + +describe('claude structured launch resolution', () => { + it('pre-mints a stable provider id and pins interactive setting sources', async () => { + const first = await resolverFor(record())({ identity: IDENTITY }) + const second = await resolverFor(record())({ identity: IDENTITY }) + + expect(first.providerSessionId).toBe(claudeSessionIdForOrcaSession(SESSION_ID)) + expect(second.providerSessionId).toBe(first.providerSessionId) + expect(first).toMatchObject({ + command: '/usr/local/bin/claude', + cwd: '/repos/workspace-1', + claudeConfigDir: '/home/work/.claude', + resumeLeafUuid: null, + resumed: false + }) + expect(first.args).toContain('--session-id') + expect(first.args).toContain(first.providerSessionId) + expect(first.args).toContain('--permission-prompt-tool') + expect(first.args).toContain('stdio') + expect(first.args).toContain('--setting-sources') + expect(first.args).toContain(CLAUDE_DEFAULT_SETTING_SOURCES.join(',')) + expect(CLAUDE_STRUCTURED_BASE_ARGS).toContain('--verbose') + }) + + it('resumes the session and leaf at the durable chain head', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } }, + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: 'leaf-current' + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: IDENTITY }) + + expect(launch).toMatchObject({ + providerSessionId: 'provider-current', + resumeLeafUuid: 'leaf-current', + resumed: true + }) + expect(launch.args.slice(-2)).toEqual(['--resume', 'provider-current']) + }) + + it('uses the runtime environment instead of the scrubbed legacy launchEnv', async () => { + const pinned = record({ + launchEnv: { + ANTHROPIC_AUTH_TOKEN: 'first-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } + }) + const resolver = resolverFor(pinned, () => ({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + })) + + expect((await resolver({ identity: IDENTITY })).env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + }) + expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') + }) + + it('refuses other hosts, WSL, providers, and account-home variables', async () => { + await expect( + resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ provider: 'codex' } as Partial))({ + identity: IDENTITY + }) + ).rejects.toThrow(/codex session/) + await expect( + resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts new file mode 100644 index 00000000000..2244f23f707 --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveClaudeCommand } from '../codex-cli/command' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' + +export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const + +export const CLAUDE_STRUCTURED_BASE_ARGS = [ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--include-partial-messages', + '--verbose', + '--replay-user-messages', + '--permission-prompt-tool', + 'stdio', + '--setting-sources', + CLAUDE_DEFAULT_SETTING_SOURCES.join(',') +] + +export type ClaudeStructuredLaunch = { + command: string + args: string[] + cwd: string + env?: Record + claudeConfigDir: string + providerSessionId: string + resumeLeafUuid: string | null + resumed: boolean +} + +export type ClaudeStructuredLaunchResolverDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveEnvironment?: () => Promise + platform?: NodeJS.Platform +} + +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +export function createClaudeStructuredLaunchResolver( + deps: ClaudeStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== 'claude') { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null + ) { + throw new Error( + `claude structured sessions run on the local host, not ${record.location.executionHostId}` + ) + } + if ((deps.platform ?? process.platform) === 'win32') { + throw new Error('claude structured sessions are unavailable on win32') + } + if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { + throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + } + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const providerSessionId = + head?.handle.provider === 'claude' + ? head.handle.sessionId + : claudeSessionIdForOrcaSession(identity.sessionId) + const providerArgs = + head?.handle.provider === 'claude' + ? ['--resume', providerSessionId] + : ['--session-id', providerSessionId] + const environment = await deps.resolveEnvironment?.() + const pathEnv = environment?.PATH ?? environment?.Path ?? null + const homePath = environment?.HOME ?? environment?.USERPROFILE + const command = (deps.resolveCommand ?? resolveClaudeCommand)({ + pathEnv, + ...(homePath ? { homePath } : {}) + }) + return { + command, + args: [...CLAUDE_STRUCTURED_BASE_ARGS, ...providerArgs], + cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + ...(environment ? { env: { ...environment } as Record } : {}), + claudeConfigDir: record.accountHome.path, + providerSessionId, + resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumed: head?.handle.provider === 'claude' + } + } +} diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts new file mode 100644 index 00000000000..63c02c7a3bd --- /dev/null +++ b/src/main/claude/claude-structured-options.ts @@ -0,0 +1,57 @@ +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import type { ClaudeSession } from './claude-structured-session-state' + +const OPTION_ORDER = ['model', 'effort', 'permissionMode'] as const + +export function restoredClaudeStructuredSessionOptions( + options: Readonly> | undefined +): Map { + return new Map( + OPTION_ORDER.flatMap((key) => { + const value = options?.[key] + return value ? [[key, value] as const] : [] + }) + ) +} + +export async function setClaudeStructuredOption( + session: ClaudeSession, + input: { key: string; value: string }, + timeoutMs: number | undefined +): Promise>> { + const request = + input.key === 'model' + ? { subtype: 'set_model', params: { model: input.value } } + : input.key === 'permissionMode' + ? { subtype: 'set_permission_mode', params: { mode: input.value } } + : input.key === 'effort' + ? { subtype: 'apply_flag_settings', params: { settings: { effortLevel: input.value } } } + : null + if (!request) { + throw new AgentSessionOptionRejectedError( + `claude stream-json has no session option named ${input.key}` + ) + } + try { + await session.connection.request(request.subtype, request.params, { timeoutMs }) + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + throw new AgentSessionOptionRejectedError(error) + } + throw error + } + session.options.set(input.key, input.value) + return Object.fromEntries(session.options) +} + +export async function restoreClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + const options = [...session.options.entries()] + session.options.clear() + for (const [key, value] of options) { + await setClaudeStructuredOption(session, { key, value }, timeoutMs) + } +} diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts new file mode 100644 index 00000000000..0473dccd4f8 --- /dev/null +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { claudeProcessIdentity } from './claude-structured-owner-identity' + +const IDENTITY = { + sessionId: 'session-identity', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: { kind: 'claude' as const, sessionId: 'provider-1', leafUuid: null } +} + +describe('claude process identity', () => { + it('records start time and spawn token', async () => { + await expect( + claudeProcessIdentity( + { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, + async () => 123 + ) + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 123, + spawnToken: 'spawn-a' + }) + }) + + it('retries unreadable start times before refusing', async () => { + const readStartTime = vi + .fn<(pid: number) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(456) + await expect( + claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ processStartTimeMs: 456 }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) + + it('refuses when start time remains unreadable', async () => { + const readStartTime = vi.fn(async () => null) + await expect( + claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).rejects.toThrow('start time') + expect(readStartTime).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index 1d13e6ec7c2..a007a3e40eb 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -1,4 +1,40 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' +const START_TIME_READ_ATTEMPTS = 3 + +export async function claudeProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + pid: number | undefined + }, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error('claude stream-json started without a pid') + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + throw new Error(`claude stream-json start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} export function claudeProviderHandleLink(input: { sessionId: string 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..2dacbffd826 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -0,0 +1,114 @@ +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 = { + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +function questionOptions( + question: Record, + questionAddress: string +): AgentJournalPromptOption[] { + if (!Array.isArray(question.options)) { + return [] + } + return question.options.flatMap((value, index) => { + const option = claudeRecord(value) + const label = claudeText(option?.label) + return label + ? [ + { + id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), + label + } + ] + : [] + }) +} + +export function claudeQuestionItems(input: { + sessionId: string + prompt: ClaudePendingPrompt +}): ClaudeQuestionItem[] { + const values = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : [] + return values.flatMap((value, index): ClaudeQuestionItem[] => { + const question = claudeRecord(value) + const questionAddress = `q${index + 1}` + const text = claudeText(question?.question) ?? claudeText(question?.header) + const header = claudeText(question?.header) + return question && input.prompt.questionIds[index] && text + ? [ + { + identity: claudePromptIdentity({ + sessionId: input.sessionId, + promptKey: input.prompt.promptKey, + questionId: questionAddress + }), + body: { + kind: 'question', + question: header ? `${header}: ${text}` : text, + options: questionOptions(question, questionAddress), + freeTextQuestionId: questionAddress, + resolution: { ...PENDING } + } + } + ] + : [] + }) +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts new file mode 100644 index 00000000000..269cd6e18a3 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -0,0 +1,233 @@ +import type { ClaudeControlRequest } from './claude-stream-json-connection' + +export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const +export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: unknown[] + questionIds: readonly string[] + answers: Map + request: ClaudeControlRequest['request'] +} + +type PromptBinding = { + address: string + questionId?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function questionsFrom(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +} + +function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { + const match = /^q([1-9]\d*)$/.exec(address) + const index = match ? Number(match[1]) - 1 : -1 + return index >= 0 ? (prompt.questionIds[index] ?? null) : null +} + +function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { + const decoded = decodeClaudeQuestionOptionId(optionId) + if (!decoded) { + return optionId + } + const questionIndex = prompt.questionIds.indexOf(questionId) + if (questionIndex === -1) { + return optionId + } + const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) + const optionIndex = choice ? Number(choice[1]) - 1 : -1 + const question = questionsFrom(prompt.input)[questionIndex] + const options = Array.isArray(question?.options) ? question.options : [] + const option = options[optionIndex] + const label = isRecord(option) ? readString(option.label) : null + if (decoded.questionId === `q${questionIndex + 1}` && label) { + return label + } + if (decoded.questionId === `q${questionIndex + 1}`) { + return decoded.answer + } + const legacyChoice = options.some( + (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + ) + return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) + ? decoded.answer + : optionId +} + +function questionId(question: Record, index: number): string { + return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` +} + +export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +export function decodeClaudeQuestionOptionId( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + + register(control: ClaudeControlRequest): ClaudePendingPrompt | null { + if (control.request.subtype !== 'can_use_tool') { + return null + } + const toolUseId = readString(control.request.tool_use_id) + const toolName = readString(control.request.tool_name) + const input = isRecord(control.request.input) ? control.request.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: control.request_id, + promptKey: control.request_id, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(control.request.permission_suggestions) + ? control.request.permission_suggestions + : [], + questionIds: questions.map(questionId), + answers: new Map(), + request: control.request + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}) + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + return pending + } +} + +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { + if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + throw new Error(`${optionId} is not a Claude approval decision`) + } + const decision = optionId as ClaudeApprovalDecision + if (decision === 'allow' || decision === 'allowForSession') { + return { + behavior: 'allow', + updatedInput: prompt.input, + ...(decision === 'allowForSession' && prompt.suggestions.length > 0 + ? { updatedPermissions: prompt.suggestions } + : {}), + toolUseID: prompt.toolUseId + } + } + return { + behavior: 'deny', + message: decision === 'cancel' ? 'User stopped this turn.' : 'User denied this action.', + ...(decision === 'cancel' ? { interrupt: true } : {}), + toolUseID: prompt.toolUseId + } +} + +function questionResponse( + prompt: ClaudePendingPrompt, + optionId: string, + boundQuestionId?: string +): Record | null { + const decoded = decodeClaudeQuestionOptionId(optionId) + const decodedQuestionId = decoded + ? (questionIdFromAddress(prompt, decoded.questionId) ?? + (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) + : null + const selectedQuestionId = + boundQuestionId ?? + decodedQuestionId ?? + (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { + throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) + } + const answer = questionAnswer(prompt, selectedQuestionId, optionId) + prompt.answers.set(selectedQuestionId, answer) + if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { + return null + } + const answers: Record = {} + for (const id of prompt.questionIds) { + answers[id] = prompt.answers.get(id) as string + } + return { + behavior: 'allow', + updatedInput: { ...prompt.input, answers }, + toolUseID: prompt.toolUseId + } +} + +export function applyClaudePromptAnswer( + found: { prompt: ClaudePendingPrompt; questionId?: string }, + optionId: string +): Record | null { + if (found.prompt.kind === 'approval') { + return approvalResponse(found.prompt, optionId) + } + return questionResponse(found.prompt, optionId, found.questionId) +} diff --git a/src/main/claude/claude-structured-provider-events.ts b/src/main/claude/claude-structured-provider-events.ts new file mode 100644 index 00000000000..f5a0c622f30 --- /dev/null +++ b/src/main/claude/claude-structured-provider-events.ts @@ -0,0 +1,43 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { settleClaudeExitedSession } from './claude-structured-session-close' +import type { + ClaudeAcquisitionAttempt, + ClaudeSession, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +export class ClaudeStructuredProviderEvents { + constructor( + private readonly sessions: Map, + private readonly onEvent?: (event: ClaudeStructuredSessionEvent) => void + ) {} + + deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void { + if (!attempt.published) { + attempt.buffered.push(event) + return + } + if (this.sessions.get(sessionId)?.connection === attempt.connection) { + event() + } + } + + handleExit(sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error): void { + const session = this.sessions.get(sessionId) + if (!session || session.connection !== attempt.connection) { + return + } + this.sessions.delete(sessionId) + this.emit(session, session.events, { type: 'ended', sessionId, reason: error.message }) + settleClaudeExitedSession(session) + } + + emit( + session: ClaudeSession | null, + _events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ): void { + session?.translator?.handle(event) + this.onEvent?.(event) + } +} diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts new file mode 100644 index 00000000000..537f136d2c9 --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -0,0 +1,52 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +export function claudeProviderFrameKind(message: Record): string { + const type = claudeText(message.type) ?? 'unknown' + const subtype = claudeText(message.subtype) + const eventType = claudeText(claudeRecord(message.event)?.type) + return ['message', type, subtype ?? eventType].filter(Boolean).join(':') +} + +export function isModeledClaudeContent(value: unknown): boolean { + const part = claudeRecord(value) + if (!part) { + return false + } + if (part.type === 'text') { + return claudeText(part.text) !== null + } + if (part.type === 'image') { + const source = claudeRecord(part.source) + return source?.type === 'url' && claudeText(source.url) !== null + } + if (part.type === 'tool_use') { + return claudeText(part.id) !== null && claudeText(part.name) !== null + } + if (part.type === 'tool_result') { + return claudeText(part.tool_use_id) !== null + } + return part.type === 'thinking' && claudeText(part.thinking) !== null +} + +export function createClaudeProviderFrameFallback(sink: StructuredAgentSessionEventSink): { + append: (kind: string, payload: unknown) => void +} { + let sequence = 0 + return { + append: (kind, payload) => { + const translated = unhandledProviderFrameJournalItem('claude', kind, payload) + if (!translated) { + return + } + sequence += 1 + sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame:claude:${sequence}` }, + translated.body, + translated.blobs + ) + sink.publish() + } + } +} diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts new file mode 100644 index 00000000000..f91245fec4c --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -0,0 +1,667 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' +import { + CLAUDE_STRUCTURED_INIT_TIMEOUT_MS, + ClaudeStructuredSessionAdapter, + type ClaudeStructuredLaunch, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' + +const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + } +} + +type Route = (params: Record | undefined) => unknown + +type FakeConnection = Omit & { + closed: boolean + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] + replies: { requestId: string; response?: unknown; error?: string }[] + closeCount: number +} + +function fakeClaude( + options: { + initSessionId?: string + initUuid?: string + initModel?: string + initEffort?: string + initProof?: 'init' | 'session-start' | 'none' + initAccount?: unknown + exitBeforeInit?: string + settings?: unknown + replayUuid?: string | null + routes?: Record + } = {} +): { + connections: FakeConnection[] + openConnection: typeof openClaudeStreamJsonConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const routes = options.routes ?? {} + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + sent: [], + replies: [], + closeCount: 0, + pid: 4321, + closed: false, + request: async (subtype, params) => { + connection.calls.push({ subtype, params }) + if (subtype === 'initialize') { + if (options.exitBeforeInit) { + handlers.onExit?.(new Error(options.exitBeforeInit)) + return { models: [] } + } + if (options.initProof === 'session-start') { + handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid' + }) + } else if (options.initProof !== 'none') { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + effortLevel: options.initEffort ?? 'high', + apiKeySource: 'none' + }) + } + return { + models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + ...(options.initAccount === undefined ? {} : { account: options.initAccount }) + } + } + if (subtype === 'get_settings') { + return options.settings ?? { env: {} } + } + const route = routes[subtype] + return route ? route(params) : {} + }, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user' && options.replayUuid !== null) { + handlers.onMessage?.({ + ...message, + uuid: options.replayUuid ?? 'user-uuid' + }) + } + }, + respond: async (requestId, response) => { + connection.replies.push({ requestId, response }) + }, + respondWithError: async (requestId, error) => { + connection.replies.push({ requestId, error }) + }, + close: async () => { + connection.closeCount += 1 + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + return { connections, openConnection, routes } +} + +function adapterFor( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [], + persistedHandles: unknown[] = [], + initTimeoutMs?: number +): ClaudeStructuredSessionAdapter { + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'claude', + args: ['-p'], + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), + dispatchAckTimeoutMs: 10, + persistHandle: async (handle) => { + persistedHandles.push(handle) + } + }) +} + +async function acquired( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [] +): Promise { + const adapter = adapterFor(claude, launch, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('finishes its startup deadline before the paired mobile request deadline', () => { + expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000) + }) + + it('pins the account, proves init, and reports the process and chain leaf', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(claude.connections[0].launch).toMatchObject({ + cwd: '/work/repo', + env: { + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9', + CLAUDE_CONFIG_DIR: '/accounts/claude' + } + }) + expect(claude.connections[0].calls.slice(0, 2)).toEqual([ + { subtype: 'initialize', params: { supportedDialogKinds: [] } }, + { subtype: 'get_settings', params: {} } + ]) + expect(acquisition.process).toEqual({ + hostId: 'host-1', + pid: 4321, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-9' + }) + expect(acquisition.link).toMatchObject({ + handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1_700_000_000_500 + }) + expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } }) + }) + + it('restores persisted model and effort before publishing a reacquired session', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { resumed: true }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', effort: 'high' } + }) + + expect(claude.connections[0].calls.slice(-2)).toEqual([ + { subtype: 'set_model', params: { model: 'opus' } }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } } + ]) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'opus', effort: 'high' } + }) + }) + + it('forwards configured launch environment while keeping ownership pins authoritative', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { + env: { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/wrong/account', + [CLAUDE_SPAWN_TOKEN_ENV]: 'wrong-token' + } + }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + expect(claude.connections[0].launch.env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/accounts/claude', + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' + }) + }) + + it('accepts SessionStart as the pre-turn session proof from the real CLI protocol', async () => { + const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: null + }) + expect(events[0]).toMatchObject({ + type: 'message', + message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } + }) + }) + + it('records only non-secret effective auth-lane diagnostics', async () => { + const claude = fakeClaude({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + ANTHROPIC_AUTH_TOKEN: 'secret' + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + await acquired(claude, {}, events) + + const diagnostic = events.find((event) => event.type === 'auth-diagnostic') + expect(diagnostic).toEqual({ + type: 'auth-diagnostic', + sessionId: 'session-1', + diagnostic: { + apiKeySourceConfigured: false, + baseUrlConfigured: true, + authTokenConfigured: true, + apiKeyConfigured: false, + settingSources: ['user', 'project', 'local'] + } + }) + expect(JSON.stringify(diagnostic)).not.toContain('secret') + expect(JSON.stringify(diagnostic)).not.toContain('gateway.example.test') + }) + + it('resumes the same provider id and refuses an init proof for another session', async () => { + const resumedClaude = fakeClaude() + const resumed = adapterFor(resumedClaude, { + resumed: true, + resumeLeafUuid: 'leaf-before' + }) + const acquisition = await resumed.acquire({ + identity: identityFor(), + fence: 9, + spawnToken: 'spawn-9' + }) + expect(acquisition.link.origin).toBe('resumed') + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'leaf-before' + }) + + const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) + const wrong = adapterFor(wrongClaude) + await expect( + wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/expected/) + expect(wrongClaude.connections[0].closeCount).toBe(1) + }) + + it('surfaces a CLI startup failure instead of waiting for the init deadline', async () => { + const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('Claude login required') + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('closes a silent unauthenticated startup with actionable account guidance', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const adapter = adapterFor(claude, {}, [], [], 20) + + const error = await adapter + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(error).toMatchObject({ + message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s) + }) + expect(claude.connections[0].calls[0]).toEqual({ + subtype: 'initialize', + params: { supportedDialogKinds: [] } + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { + const claude = fakeClaude({ + initProof: 'session-start', + initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + expect(claude.connections[0].closeCount).toBe(1) + }) +}) + +describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it('accepts a dispatch only after Claude replays its provider uuid', async () => { + const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) + const adapter = await acquired(claude) + + const result = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(result).toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: 'user-provider-uuid' + } + }) + expect(claude.connections[0].sent[0]).toMatchObject({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] }, + session_id: PROVIDER_SESSION_ID + }) + }) + + it('leaves delivery unconfirmed when no replay uuid arrives', async () => { + const adapter = await acquired(fakeClaude({ replayUuid: null })) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('requires an acknowledged interrupt and supports controlled options', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + ).resolves.toEqual({ model: 'sonnet' }) + expect(claude.connections[0].calls.slice(-2)).toEqual([ + { subtype: 'interrupt', params: {} }, + { subtype: 'set_model', params: { model: 'sonnet' } } + ]) + + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + + claude.routes.interrupt = () => { + throw new Error('claude interrupt request timed out') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('classifies provider-declined options without treating timeouts as settled', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + claude.routes.set_model = () => { + throw new Error('claude set_model request timed out') + } + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { + list_models: () => ({ + models: [ + { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet' + } + ] + }) + } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } + ], + current: { model: 'sonnet', effort: 'high' } + }) + }) + + it('keeps the shared Claude seed when live model discovery is unavailable', async () => { + const claude = fakeClaude({ + initModel: 'custom-model', + routes: { + list_models: () => { + throw new Error('unsupported') + } + } + }) + const adapter = await acquired(claude) + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + + expect(result.models.map((model) => model.id)).toEqual([ + 'fable', + 'opus', + 'sonnet', + 'haiku', + 'custom-model' + ]) + expect(result.current).toEqual({ model: 'custom-model', effort: 'high' }) + }) +}) + +describe('ClaudeStructuredSessionAdapter prompts', () => { + it('turns can_use_tool into an addressable durable approval callback', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + claude.connections[0].handlers.onControlRequest?.({ + type: 'control_request', + request_id: 'permission-1', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'tool-1', + input: { command: 'git status' }, + permission_suggestions: [{ type: 'addRules' }] + } + }) + expect(events.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } + }) + + adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-approval', + kind: 'approval', + optionId: 'allowForSession', + fence: 7 + }) + expect(claude.connections[0].replies).toEqual([ + { + requestId: 'permission-1', + response: { + behavior: 'allow', + updatedInput: { command: 'git status' }, + updatedPermissions: [{ type: 'addRules' }], + toolUseID: 'tool-1' + } + } + ]) + }) + + it('collects every AskUserQuestion card before answering the one callback', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + claude.connections[0].handlers.onControlRequest?.({ + type: 'control_request', + request_id: 'question-1', + request: { + subtype: 'can_use_tool', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship now?', options: [{ label: 'Yes' }] } + ] + } + } + }) + adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?') + adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?') + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q1', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), + fence: 7 + }) + expect(claude.connections[0].replies).toEqual([]) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q2', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), + fence: 7 + }) + expect(claude.connections[0].replies[0]).toMatchObject({ + requestId: 'question-1', + response: { + behavior: 'allow', + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } }, + toolUseID: 'tool-question' + } + }) + }) + + it('does not move the persisted cursor from a result-frame uuid', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const adapter = adapterFor( + claude, + { resumeLeafUuid: 'previous-leaf', resumed: true }, + events, + persistedHandles + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'result', + session_id: PROVIDER_SESSION_ID, + uuid: 'result-uuid' + }) + + await adapter.closeSession('session-1') + + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'previous-leaf', + fence: 7 + } + ]) + expect(events.at(-2)).toEqual({ + type: 'handle', + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'previous-leaf', + fence: 7 + }) + expect(claude.connections[0].closeCount).toBe(1) + }) +}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts new file mode 100644 index 00000000000..9c91f106e81 --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -0,0 +1,272 @@ +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + AgentSessionPreSpawnError as PreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup as rethrowAfterCleanup +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { claudeAuthDiagnostic, readClaudeModels } from './claude-structured-init-proof' +import { + createClaudeInitDeadline, + requestClaudeInitialization +} from './claude-structured-init-deadline' +import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' +import { ClaudeStructuredAcquisitionEvents } from './claude-structured-acquisition-events' +import { + restoreClaudeStructuredSessionOptions, + restoredClaudeStructuredSessionOptions, + 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 { ClaudeStructuredProviderEvents } from './claude-structured-provider-events' +import { + cancelClaudeAcquisitionAttempt, + ClaudeAcquisitionRegistry, + type ClaudeSession, + type ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' +import { closeClaudePublishedSession } from './claude-structured-session-close' + +export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +export type { + ClaudeAuthDiagnostic, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 +const DISPATCH_ACK_TIMEOUT_MS = 10_000 + +export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquisitions = new ClaudeAcquisitionRegistry() + private readonly events: ClaudeStructuredProviderEvents + + constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) { + this.events = new ClaudeStructuredProviderEvents(this.sessions, deps.onEvent) + } + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => + agent === 'claude' && + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform === 'darwin' || process.platform === 'linux') + + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + this.supportsCreate(location, 'claude') + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const prompts = new ClaudePromptRegistry() + const translator = createClaudeSessionJournalTranslator(input.events, prompts) + const { previous, attempt } = this.acquisitions.start(sessionId, prompts) + const initTimeoutMs = this.deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS + const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + const acquisitionEvents = new ClaudeStructuredAcquisitionEvents( + sessionId, + attempt, + input.events, + initDeadline, + this.events + ) + + try { + if (!(await cancelClaudeAcquisitionAttempt(previous))) { + throw new Error(`superseded claude session ${sessionId} did not prove provider exit`) + } + this.acquisitions.assertCurrent(sessionId, attempt) + await this.closePublishedSession(sessionId) + this.acquisitions.assertCurrent(sessionId, attempt) + const launch = await this.deps + .resolveLaunch({ identity: input.identity }) + .catch((error: unknown) => { + throw new PreSpawnError(error) + }) + acquisitionEvents.observeResumeLeaf(launch.resumeLeafUuid) + this.acquisitions.assertCurrent(sessionId, attempt) + const open = this.deps.openConnection ?? openClaudeStreamJsonConnection + const connection = await open( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...launch.env, + [CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken, + CLAUDE_CONFIG_DIR: launch.claudeConfigDir + } + }, + { + onMessage: acquisitionEvents.onMessage, + onControlRequest: acquisitionEvents.onControlRequest, + onControlCancelRequest: acquisitionEvents.onControlCancelRequest, + onExit: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + this.events.handleExit(sessionId, attempt, error) + } + } + ) + attempt.connection = connection + this.acquisitions.assertCurrent(sessionId, attempt) + initDeadline.start() + const [initialization, init] = await Promise.all([ + requestClaudeInitialization(connection, sessionId, initTimeoutMs), + initDeadline.promise + ]) + const models = readClaudeModels(initialization) + acquisitionEvents.emit({ type: 'options', sessionId, models }) + initDeadline.clear() + this.acquisitions.assertCurrent(sessionId, attempt) + if (init.providerSessionId !== launch.providerSessionId) { + throw new Error( + `claude proved session ${init.providerSessionId}, expected ${launch.providerSessionId}` + ) + } + const settings = await connection + .request('get_settings', {}, { timeoutMs: this.deps.requestTimeoutMs }) + .catch(() => null) + acquisitionEvents.emit({ + type: 'auth-diagnostic', + sessionId, + diagnostic: claudeAuthDiagnostic(init, settings) + }) + const process = await claudeProcessIdentity( + { ...input, pid: connection.pid }, + this.deps.readProcessStartTime + ) + this.acquisitions.assertCurrent(sessionId, attempt) + if (connection.closed) { + throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`) + } + const publication = createClaudeSessionPublication({ + connection, + init, + leafUuid: acquisitionEvents.observedLeaf(), + fence: input.fence, + resumed: launch.resumed, + prompts, + translator, + events: input.events, + process, + options: restoredClaudeStructuredSessionOptions(input.options), + ...(this.deps.mintLinkId ? { linkId: this.deps.mintLinkId() } : {}), + observedAt: this.deps.now?.() ?? Date.now() + }) + const acquired: AgentSessionAcquisition = publication.acquisition + const liveSession = publication.session + acquisitionEvents.publish(liveSession) + await restoreClaudeStructuredSessionOptions(liveSession, this.deps.requestTimeoutMs) + this.acquisitions.assertCurrent(sessionId, attempt) + this.acquisitions.deleteIfCurrent(sessionId, attempt) + this.sessions.set(sessionId, liveSession) + attempt.published = true + for (const event of attempt.buffered.splice(0)) { + event() + } + return acquired + } catch (error) { + initDeadline.clear() + this.acquisitions.deleteIfCurrent(sessionId, attempt) + if (this.sessions.get(sessionId)?.connection !== attempt.connection) { + translator?.dispose() + prompts.clear() + await rethrowAfterCleanup( + { + releaseAcquisition: async () => + attempt.connection ? await attempt.connection.close() : true + }, + sessionId, + error + ) + } + throw error + } finally { + attempt.finish() + } + } + + bindPromptItemId( + sessionId: string, + journalItemId: string, + promptKey: string, + questionId?: string + ): void { + this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeTurn( + this.session(input.sessionId), + input, + this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS + ) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => + cancelClaudeTurn(this.session(input.sessionId), this.deps.requestTimeoutMs) + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + answerClaudePrompt(this.session(input.sessionId), input) + + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + + readOptions = (input: { sessionId: string; fence: number }) => + readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + releaseAcquisition(input: { sessionId: string }): Promise { + return this.closeSession(input.sessionId) + } + + async closeSession(sessionId: string): Promise { + const attempt = this.acquisitions.get(sessionId) + if (attempt) { + attempt.cancelled = true + const exited = attempt.connection ? await attempt.connection.close() : true + await attempt.finished + if (!exited) { + return false + } + } + return this.closePublishedSession(sessionId) + } + + disposeSession = (sessionId: string): Promise => this.closeSession(sessionId) + + private closePublishedSession(sessionId: string): Promise { + return closeClaudePublishedSession({ + sessions: this.sessions, + sessionId, + ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + } + + async closeAll(): Promise { + this.acquisitions.close() + const ids = new Set([...this.sessions.keys(), ...this.acquisitions.sessionIds()]) + const outcomes = await Promise.all([...ids].map((sessionId) => this.closeSession(sessionId))) + if (outcomes.some((exited) => !exited)) { + throw new Error('claude structured session teardown could not prove provider-child exit') + } + } + + private session(sessionId: string): ClaudeSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session + } +} diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts new file mode 100644 index 00000000000..5f45754cbc7 --- /dev/null +++ b/src/main/claude/claude-structured-session-close.ts @@ -0,0 +1,79 @@ +import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +export function settleClaudeDispatchWaiters(session: ClaudeSession): void { + for (const waiter of session.dispatchWaiters.splice(0)) { + clearTimeout(waiter.timer) + waiter.resolve(null) + } +} + +export function settleClaudeExitedSession(session: ClaudeSession): void { + settleClaudeDispatchWaiters(session) + session.prompts.clear() + session.translator?.dispose() +} + +export async function closeClaudePublishedSession(input: { + sessions: Map + sessionId: string + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void +}): Promise { + const session = input.sessions.get(input.sessionId) + if (!session) { + return true + } + settleClaudeDispatchWaiters(session) + const pending = session.prompts.clear() + await Promise.allSettled( + pending.map((prompt) => + session.connection.respond(prompt.requestId, { + behavior: 'deny', + message: 'Structured Claude session closed.', + interrupt: true, + toolUseID: prompt.toolUseId + }) + ) + ) + session.translator?.flush() + if (!(await session.connection.close())) { + return false + } + input.sessions.delete(input.sessionId) + let persistenceError: unknown + try { + await input.persistHandle?.({ + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + input.onEvent?.({ + type: 'handle', + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + } catch (error) { + persistenceError = error + } finally { + const ended = { + type: 'ended', + sessionId: input.sessionId, + reason: 'claude session closed' + } as const + session.translator?.handle(ended) + input.onEvent?.(ended) + session.translator?.dispose() + } + if (persistenceError) { + throw persistenceError + } + return true +} diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts new file mode 100644 index 00000000000..ec9debc1caa --- /dev/null +++ b/src/main/claude/claude-structured-session-options.ts @@ -0,0 +1,112 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { CatalogModel } from '../../shared/agent-session-option-catalog-types' +import type { ClaudeSession } from './claude-structured-session-state' + +type ListedModel = AgentSessionModelOption & { resolvedModel: string | null } + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function effortLabel(value: string): string { + return value === 'xhigh' ? 'Extra high' : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function listedEfforts(row: Record): AgentSessionOptionChoice[] { + return row.supportsEffort === true && Array.isArray(row.supportedEffortLevels) + ? row.supportedEffortLevels.flatMap((value) => { + const effort = text(value) + return effort ? [{ value: effort, label: effortLabel(effort) }] : [] + }) + : [] +} + +function listedModels(value: unknown): ListedModel[] { + const response = record(value) + const rows = Array.isArray(response?.models) + ? response.models.map(record).filter((row): row is Record => row !== null) + : [] + const defaultRow = rows.find((row) => text(row.value) === 'default') + const defaultResolvedModel = text(defaultRow?.resolvedModel) + const seen = new Set() + return rows.flatMap((row) => { + const id = text(row.value) + if (!id || id === 'default' || seen.has(id)) { + return [] + } + seen.add(id) + const resolvedModel = text(row.resolvedModel) + const description = text(row.description) + return [ + { + id, + label: text(row.displayName) ?? id, + ...(description ? { description } : {}), + isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel, + efforts: listedEfforts(row), + resolvedModel + } + ] + }) +} + +function seedEfforts(model: CatalogModel): AgentSessionOptionChoice[] { + const effort = model.options.find((option) => option.id === 'effort') + return effort?.kind.type === 'select' ? effort.kind.choices : [] +} + +function seedModels(): ListedModel[] { + return CLAUDE_SESSION_OPTION_CATALOG.models.map((model) => ({ + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + isDefault: model.isDefault === true, + efforts: seedEfforts(model), + resolvedModel: null + })) +} + +function currentModelId(models: ListedModel[], reportedModel: string | undefined): string { + const matched = reportedModel + ? models.find((model) => model.id === reportedModel || model.resolvedModel === reportedModel) + : undefined + return ( + matched?.id ?? reportedModel ?? models.find((model) => model.isDefault)?.id ?? models[0]!.id + ) +} + +export async function readClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + const response = await session.connection + .request('list_models', {}, { timeoutMs }) + .catch(() => null) + const discovered = listedModels(response) + const models = discovered.length > 0 ? discovered : seedModels() + const reportedModel = session.options.get('model') ?? session.reportedOptions.model + const model = currentModelId(models, reportedModel) + if (!models.some((entry) => entry.id === model)) { + models.push({ id: model, label: model, isDefault: false, efforts: [], resolvedModel: null }) + } + const effort = session.options.get('effort') ?? session.reportedOptions.effort + return { + models: models.map((entry) => { + const { resolvedModel, ...withoutResolvedModel } = entry + void resolvedModel + return withoutResolvedModel + }), + current: { model, ...(effort ? { effort } : {}) } + } +} diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts new file mode 100644 index 00000000000..35fa27b65e6 --- /dev/null +++ b/src/main/claude/claude-structured-session-publication.ts @@ -0,0 +1,52 @@ +import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +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: { + connection: ClaudeSession['connection'] + init: ClaudeInitObservation + leafUuid: string | null + fence: number + resumed: boolean + prompts: ClaudePromptRegistry + translator: ClaudeJournalTranslator | null + events: ClaudeSession['events'] + process: AgentSessionAcquisition['process'] + linkId?: string + observedAt: number + options?: ReadonlyMap +}): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { + const model = readClaudeFrameString(input.init.message, 'model') + const effort = readClaudeFrameString(input.init.message, 'effortLevel') + return { + acquisition: { + process: input.process, + link: claudeProviderHandleLink({ + sessionId: input.init.providerSessionId, + leafUuid: input.leafUuid, + resumed: input.resumed, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.observedAt + }) + }, + session: { + connection: input.connection, + providerSessionId: input.init.providerSessionId, + leafUuid: input.leafUuid, + fence: input.fence, + prompts: input.prompts, + dispatchWaiters: [], + options: new Map(input.options), + reportedOptions: { + ...(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 new file mode 100644 index 00000000000..b1060887d56 --- /dev/null +++ b/src/main/claude/claude-structured-session-state.ts @@ -0,0 +1,177 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + ClaudeStreamJsonConnection, + 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 = { + apiKeySourceConfigured: boolean + baseUrlConfigured: boolean + authTokenConfigured: boolean + apiKeyConfigured: boolean + settingSources: readonly string[] +} + +export type ClaudeStructuredSessionEvent = + | { type: 'message'; sessionId: string; message: Record } + | { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown } + | { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt } + | { type: 'prompt-cancelled'; sessionId: string; promptKey: string } + | { type: 'options'; sessionId: string; models: unknown[] } + | { + type: 'handle' + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + } + | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } + | { type: 'ended'; sessionId: string; reason: string } + +export type ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: (input: { + identity: AgentSessionJournalIdentity + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + openConnection?: typeof openClaudeStreamJsonConnection + readProcessStartTime?: (pid: number) => Promise + mintLinkId?: () => string + now?: () => number + requestTimeoutMs?: number + initTimeoutMs?: number + dispatchAckTimeoutMs?: number + persistHandle?: (input: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise +} + +/** The only stream-frame path allowed to advance Claude's advisory transcript cursor. */ +export function observeClaudeTopLevelLeaf( + currentLeaf: string | null, + message: Record +): string | null { + if ( + (message.type !== 'user' && message.type !== 'assistant') || + message.parent_tool_use_id !== null || + typeof message.uuid !== 'string' || + message.uuid.length === 0 + ) { + return currentLeaf + } + return message.uuid +} + +export type ClaudeDispatchWaiter = { + resolve: (uuid: string | null) => void + timer: ReturnType + acceptsResult: boolean +} + +export type ClaudeSession = { + connection: ClaudeStreamJsonConnection + providerSessionId: string + leafUuid: string | null + fence: number + prompts: ClaudePromptRegistry + dispatchWaiters: ClaudeDispatchWaiter[] + options: Map + reportedOptions: { model?: string; effort?: string } + translator: ClaudeJournalTranslator | null + events: StructuredAgentSessionEventSink | undefined +} + +export type ClaudeAcquisitionAttempt = { + connection: ClaudeStreamJsonConnection | null + prompts: ClaudePromptRegistry + buffered: (() => void)[] + published: boolean + cancelled: boolean + finished: Promise + finish: () => void +} + +export function createClaudeAcquisitionAttempt( + prompts: ClaudePromptRegistry +): ClaudeAcquisitionAttempt { + let finish = (): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + return { + connection: null, + prompts, + buffered: [], + published: false, + cancelled: false, + finished, + finish + } +} + +export class ClaudeAcquisitionRegistry { + private readonly attempts = new Map() + private closing = false + + get size(): number { + return this.attempts.size + } + + start( + sessionId: string, + prompts: ClaudePromptRegistry + ): { + previous: ClaudeAcquisitionAttempt | undefined + attempt: ClaudeAcquisitionAttempt + } { + if (this.closing) { + throw new Error('claude structured session adapter is closing') + } + const previous = this.attempts.get(sessionId) + const attempt = createClaudeAcquisitionAttempt(prompts) + this.attempts.set(sessionId, attempt) + return { previous, attempt } + } + + assertCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) { + throw new Error(`claude session ${sessionId} was superseded while being acquired`) + } + } + + get(sessionId: string): ClaudeAcquisitionAttempt | undefined { + return this.attempts.get(sessionId) + } + + deleteIfCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.attempts.get(sessionId) === attempt) { + this.attempts.delete(sessionId) + } + } + + sessionIds(): IterableIterator { + return this.attempts.keys() + } + + close(): void { + this.closing = true + } +} + +export async function cancelClaudeAcquisitionAttempt( + attempt: ClaudeAcquisitionAttempt | undefined +): Promise { + if (!attempt) { + return true + } + attempt.cancelled = true + const exited = attempt.connection ? await attempt.connection.close() : true + await attempt.finished + return exited +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 8d11df995a6..a0a0ee12bd3 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -189,6 +189,12 @@ const CODEX_ITEM_CLASSIFICATIONS: Record = contextCompaction: 'status-chrome' } +const CLAUDE_CONTROL_FRAME_CLASSIFICATIONS: Record = { + 'control_request:can_use_tool': 'timeline-substantive', + 'control_request:request_user_dialog': 'timeline-substantive', + control_cancel_request: 'timeline-substantive' +} + function notificationKind(kind: string): string { return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind } @@ -215,7 +221,10 @@ function catalogClassification( ] } if (provider === 'claude') { - return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] + return ( + PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] ?? + CLAUDE_CONTROL_FRAME_CLASSIFICATIONS[kind] + ) } return undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts new file mode 100644 index 00000000000..f923eea6745 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest' +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' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { adapterSupportsCreate } from './structured-agent-session-provider-support' + +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' +} + +function identity(agent: 'claude' | 'codex', sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent, + providerHandle: + agent === 'claude' + ? { kind: 'claude', sessionId: 'provider-1', leafUuid: null } + : { kind: 'codex', threadId: 'thread-1' } + } +} + +function fakeAdapter(agent: 'claude' | 'codex') { + const dispatch = vi.fn(async () => ({ state: 'rejected' as const, reason: agent })) + const closeSession = vi.fn(async () => true) + const releaseAcquisition = vi.fn(async () => true) + const adapter = { + supportsCreate: vi.fn( + (_location: AgentSessionExecutionLocation, candidate: string) => candidate === agent + ), + acquire: vi.fn(async () => ({ + process: { + hostId: 'local', + pid: agent === 'claude' ? 101 : 102, + processStartTimeMs: 1, + spawnToken: 'spawn' + }, + link: + agent === 'claude' + ? { + linkId: 'claude-link', + handle: { provider: 'claude' as const, sessionId: 'provider-1', leafUuid: null }, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1 + } + : { + linkId: 'codex-link', + handle: { provider: 'codex' as const, threadId: 'thread-1' }, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1 + } + })), + releaseAcquisition, + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => {}), + setOption: vi.fn(async () => {}), + readOptions: vi.fn(async () => ({ + models: [], + current: { model: `${agent}-model` } + })), + historyFilePath: vi.fn(async () => null), + closeSession, + disposeSession: closeSession + } satisfies StructuredAgentSessionAdapter + return { adapter, dispatch, closeSession, releaseAcquisition } +} + +function dispatchInput(sessionId: string) { + return { + sessionId, + clientMessageId: 'client-1', + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + fence: 1 + } +} + +describe('StructuredAgentSessionAdapterRouter', () => { + it('routes every acquired session through its provider owner, including Codex', async () => { + const claude = fakeAdapter('claude') + const codex = fakeAdapter('codex') + const router = new StructuredAgentSessionAdapterRouter( + { claude: claude.adapter, codex: codex.adapter }, + async () => {} + ) + await router.acquire({ + identity: identity('codex', 'codex-session'), + fence: 1, + spawnToken: 's' + }) + await router.acquire({ + identity: identity('claude', 'claude-session'), + fence: 1, + spawnToken: 's' + }) + + await router.dispatch(dispatchInput('codex-session')) + await router.dispatch(dispatchInput('claude-session')) + + expect(codex.dispatch).toHaveBeenCalledTimes(1) + expect(claude.dispatch).toHaveBeenCalledTimes(1) + }) + + it('makes Claude available through router capability, not the Codex fallback', () => { + const claude = fakeAdapter('claude') + const codex = fakeAdapter('codex') + const router = new StructuredAgentSessionAdapterRouter( + { claude: claude.adapter, codex: codex.adapter }, + async () => {} + ) + + expect(adapterSupportsCreate(router, LOCATION, 'claude')).toBe(true) + expect(claude.adapter.supportsCreate).toHaveBeenCalledWith(LOCATION, 'claude') + expect(codex.adapter.supportsCreate).toHaveBeenCalledTimes(0) + }) + + it('retains the owner until provider-child exit is proven', async () => { + const claude = fakeAdapter('claude') + const codex = fakeAdapter('codex') + codex.closeSession.mockResolvedValueOnce(false) + const router = new StructuredAgentSessionAdapterRouter( + { claude: claude.adapter, codex: codex.adapter }, + async () => {} + ) + await router.acquire({ identity: identity('codex', 'session-1'), fence: 1, spawnToken: 's' }) + + expect(await router.closeSession('session-1')).toBe(false) + await expect(router.dispatch(dispatchInput('session-1'))).resolves.toMatchObject({ + reason: 'codex' + }) + }) + + it('proves unknown-owner cleanup only when every adapter proves absence', async () => { + const claude = fakeAdapter('claude') + const codex = fakeAdapter('codex') + claude.releaseAcquisition.mockResolvedValueOnce(false) + const router = new StructuredAgentSessionAdapterRouter( + { claude: claude.adapter, codex: codex.adapter }, + async () => {} + ) + + expect(await router.releaseAcquisition({ sessionId: 'unknown-1' })).toBe(false) + expect(await router.releaseAcquisition({ sessionId: 'unknown-2' })).toBe(true) + }) +}) 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..fbff7bfde66 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -0,0 +1,127 @@ +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.supportsCreate?.(location, agent) ?? 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) { + const exited = (await adapter.releaseAcquisition?.(input)) === true + if (exited) { + this.owners.delete(input.sessionId) + } + return exited + } + return this.cleanupUnknownOwner((candidate) => 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 closeSession(sessionId: string): Promise { + const adapter = this.owners.get(sessionId) + if (!adapter) { + return this.cleanupUnknownOwner((candidate) => candidate.closeSession?.(sessionId)) + } + const exited = (await adapter.closeSession?.(sessionId)) === true + if (exited) { + this.owners.delete(sessionId) + } + return exited + } + + async disposeSession(sessionId: string): Promise { + const adapter = this.owners.get(sessionId) + if (!adapter) { + return this.cleanupUnknownOwner((candidate) => + (candidate.disposeSession ?? candidate.closeSession)?.(sessionId) + ) + } + const dispose = adapter.disposeSession ?? adapter.closeSession + const exited = (await dispose?.call(adapter, sessionId)) === true + if (exited) { + this.owners.delete(sessionId) + } + return exited + } + + 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 + } + + private async cleanupUnknownOwner( + cleanup: (adapter: StructuredAgentSessionAdapter) => Promise | undefined + ): Promise { + const outcomes = await Promise.allSettled( + Object.values(this.adapters).map(async (adapter) => (await cleanup(adapter)) === true) + ) + return outcomes.every((outcome) => outcome.status === 'fulfilled' && outcome.value === true) + } +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index ce916bc6769..ef40bd7866d 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -11,12 +11,19 @@ import { existsSync } from 'node:fs' 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 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' @@ -55,15 +62,19 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] + readClaudeProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv + resolveClaudeOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void handoffTransport?: StructuredAgentSessionHandoffTransport reapOrphanChildren?: typeof stopOrphanAgentSessionChildren @@ -71,7 +82,7 @@ export type StructuredAgentSessionRuntimeDeps = { type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: StructuredAgentSessionAdapterRouter /** Resolves after every adapter-exit recovery callback has settled. */ waitForRecovery: () => Promise } @@ -89,7 +100,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 provider 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 @@ -118,11 +129,13 @@ export async function stopStructuredAgentSessionRuntime(): Promise { async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() - const resolveEnvironment = async (): Promise => ({ + const resolveEnvironment = async ( + provider: AgentSessionRecord['provider'] + ): Promise => ({ ...(await bootEnvironment), ...(await deps.resolveLaunchEnv?.()), ...(await deps.resolveLaunchEnvOverlay?.()), - ...deps.resolveCodexOverrides?.() + ...(provider === 'claude' ? deps.resolveClaudeOverrides?.() : deps.resolveCodexOverrides?.()) }) const store = await AgentSessionRecordStore.open({ directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), @@ -151,7 +164,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise resolveEnvironment('codex'), ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) }), ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), @@ -172,7 +185,36 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise resolveEnvironment('claude'), + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}) + }), + ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + readProcessStartTime: deps.readClaudeProcessStartTime ?? deps.readProcessStartTime, + persistHandle: async (observed) => { + const now = Date.now() + await store.transitionHandoff(observed.sessionId, (record) => + recordAgentSessionProviderHandle({ + record, + fence: record.lease.runtimeFence, + link: claudeProviderHandleLink({ + sessionId: observed.providerSessionId, + leafUuid: observed.leafUuid, + resumed: record.providerHandleChain.length > 0, + fence: record.lease.runtimeFence, + observedAt: now + }), + now + }) + ) + } + }) + const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => { + await Promise.all([claude.closeAll(), codex.closeAll()]) + }) host = new StructuredAgentSessionHost({ store, adapter,