feat: add Claude structured session adapter

This commit is contained in:
Brennan Benson
2026-08-10 02:28:23 -07:00
parent 9202192f10
commit a500e62668
18 changed files with 2352 additions and 0 deletions
@@ -0,0 +1,128 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import type { spawn } from 'node:child_process'
import {
openClaudeStreamJsonConnection,
type ClaudeControlRequest
} from './claude-stream-json-connection'
type FakeChild = EventEmitter & {
pid: number
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
}
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 spawnImpl = vi.fn(() => child) as unknown as typeof spawn
return { child, spawnImpl }
}
function writtenFrames(child: FakeChild): Promise<Record<string, unknown>[]> {
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<string, unknown>)
)
})
})
}
describe('Claude stream-json connection', () => {
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(
'claude',
['-p'],
expect.objectContaining({
cwd: '/work/repo',
env: expect.objectContaining({ CLAUDE_CONFIG_DIR: '/accounts/one' }),
windowsHide: true
})
)
})
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('fails closed on malformed provider output', async () => {
const process = fakeSpawn()
const onExit = vi.fn()
await openClaudeStreamJsonConnection(
{ command: 'claude', args: [], cwd: '/work' },
{ onExit },
process.spawnImpl
)
process.child.stdout.write('{not-json}\n')
await new Promise((resolve) => setImmediate(resolve))
expect(process.child.kill).toHaveBeenCalled()
expect(onExit).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }))
})
})
@@ -0,0 +1,306 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline'
import { killCodexAppServerProcessTree } from '../codex/codex-app-server-session'
export type ClaudeStreamJsonLaunch = {
command: string
args: string[]
cwd: string
env?: Record<string, string>
}
export type ClaudeControlRequest = {
type: 'control_request'
request_id: string
request: Record<string, unknown> & { subtype: string }
}
export type ClaudeControlCancelRequest = {
type: 'control_cancel_request'
request_id: string
}
export type ClaudeStreamJsonConnectionHandlers = {
onMessage?: (message: Record<string, unknown>) => void
onControlRequest?: (request: ClaudeControlRequest) => void
onControlCancelRequest?: (request: ClaudeControlCancelRequest) => void
onExit?: (error: Error) => void
}
export type ClaudeStreamJsonConnection = {
readonly pid: number | undefined
readonly closed: boolean
send: (message: Record<string, unknown>) => Promise<void>
request: (
subtype: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number }
) => Promise<unknown>
respond: (requestId: string, response: unknown) => Promise<void>
respondWithError: (requestId: string, error: string) => Promise<void>
close: () => Promise<void>
}
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<typeof setTimeout>
}
function isRecord(value: unknown): value is Record<string, unknown> {
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 spawn = spawn
): Promise<ClaudeStreamJsonConnection> {
const child = spawnImpl(launch.command, launch.args, {
cwd: launch.cwd,
env: { ...process.env, ...launch.env },
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}) as ChildProcessWithoutNullStreams
const pending = new Map<string, PendingRequest>()
let nextRequestId = 1
let stderrTail = ''
let stdoutBuffer = ''
let exited = false
let closing = false
let terminalError: Error | null = null
let writeChain: Promise<void> = Promise.resolve()
const exitPromise = new Promise<void>((resolve) => {
child.on('exit', () => {
exited = true
resolve()
})
})
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<string, unknown>): Promise<void> => {
const write = async (): Promise<void> => {
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<void>((resolve, reject) => {
child.stdin.write(line, (error) => (error ? reject(error) : resolve()))
})
}
const queued = writeChain.then(write)
writeChain = queued.catch(() => {})
return queued
}
const dispatchMessage = (message: Record<string, unknown>): 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) {
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)
return
}
if (message.type === 'control_cancel_request' && typeof message.request_id === 'string') {
handlers.onControlCancelRequest?.(message as ClaudeControlCancelRequest)
return
}
handlers.onMessage?.(message)
}
const parseLine = (line: string): void => {
if (!line.trim()) {
return
}
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch (error) {
killCodexAppServerProcessTree(child)
handleUnexpectedEnd(new Error('claude emitted invalid stream-json', { cause: error }))
return
}
if (isRecord(parsed)) {
dispatchMessage(parsed)
}
}
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
stdoutBuffer += chunk
let newline = stdoutBuffer.indexOf('\n')
while (newline !== -1) {
const line = stdoutBuffer.slice(0, newline)
stdoutBuffer = stdoutBuffer.slice(newline + 1)
if (Buffer.byteLength(line, 'utf8') > STDOUT_LINE_MAX_BYTES) {
killCodexAppServerProcessTree(child)
handleUnexpectedEnd(new Error('claude emitted an oversized stream-json line'))
return
}
parseLine(line)
newline = stdoutBuffer.indexOf('\n')
}
if (Buffer.byteLength(stdoutBuffer, 'utf8') > STDOUT_LINE_MAX_BYTES) {
killCodexAppServerProcessTree(child)
handleUnexpectedEnd(new Error('claude emitted an oversized stream-json line'))
}
})
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
})
child.on('error', (error) => {
exited = true
handleUnexpectedEnd(error)
})
child.on('close', () => {
if (stdoutBuffer.trim()) {
parseLine(stdoutBuffer)
stdoutBuffer = ''
}
handleUnexpectedEnd()
})
child.stdin.on('error', (error) => {
if (!closing) {
killCodexAppServerProcessTree(child)
handleUnexpectedEnd(error)
}
})
const request = (
subtype: string,
params: Record<string, unknown> = {},
options: { timeoutMs?: number } = {}
): Promise<unknown> => {
const requestId = `orca-${nextRequestId++}`
const promise = new Promise<unknown>((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 = async (): Promise<void> => {
if (closing) {
await exitPromise
return
}
closing = true
try {
child.stdin.end()
} catch {
// The reap below still owns the process.
}
if (!exited) {
await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS)
if (!exited) {
killCodexAppServerProcessTree(child)
await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS)
}
}
failPending(new Error('claude stream-json connection closed'))
}
return {
get pid() {
return child.pid
},
get closed() {
return closing || exited || terminalError !== null
},
send: writeLine,
request,
respond: (requestId, response) =>
writeLine({
type: 'control_response',
response: { subtype: 'success', request_id: requestId, response }
}),
respondWithError: (requestId, error) =>
writeLine({
type: 'control_response',
response: { subtype: 'error', request_id: requestId, error }
}),
close
}
}
@@ -0,0 +1,30 @@
import { applyClaudePromptAnswer } from './claude-structured-prompt-replies'
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 {
return { cancelled: false }
}
}
export async function answerClaudePrompt(
session: ClaudeSession,
input: { itemId: string; kind: 'approval' | 'question'; optionId: string }
): Promise<void> {
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)
}
@@ -0,0 +1,134 @@
import { extname } from 'node:path'
import { readFile, stat } from 'node:fs/promises'
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'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp'
}
export function resolveClaudeReplayWaiter(
session: ClaudeSession,
message: Record<string, unknown>
): void {
if (
message.type !== 'user' ||
message.parent_tool_use_id !== null ||
readClaudeFrameString(message, 'session_id') !== session.providerSessionId
) {
return
}
const uuid = readClaudeFrameString(message, 'uuid')
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
if (waiter && uuid) {
clearTimeout(waiter.timer)
waiter.resolve(uuid)
}
}
async function imageContent(
block: Extract<NativeChatBlock, { type: 'image-ref' }>
): Promise<unknown> {
if (block.url) {
return { type: 'image', source: { type: 'url', url: block.url } }
}
if (!block.path) {
throw new Error('image reference has neither a path nor a URL')
}
const info = await stat(block.path)
if (!info.isFile() || info.size > MAX_IMAGE_BYTES) {
throw new Error(`Claude image must be a file no larger than ${MAX_IMAGE_BYTES} bytes`)
}
const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()]
if (!mediaType) {
throw new Error(`Claude does not support the image type ${extname(block.path)}`)
}
return {
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: (await readFile(block.path)).toString('base64')
}
}
}
async function messageContent(body: AgentJournalMessageItem): Promise<unknown[]> {
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') {
content.push(await imageContent(block))
}
}
if (content.length === 0) {
throw new Error('Claude dispatch requires text or an image')
}
return content
}
function waitForReplay(session: ClaudeSession, timeoutMs: number): Promise<string | null> {
return new Promise((resolve) => {
const waiter = {
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<AgentSessionDispatchOutcome> {
let content: unknown[]
try {
content = await messageContent(input.body)
} catch (error) {
return { state: 'rejected', reason: (error as Error).message }
}
const replayed = waitForReplay(session, timeoutMs)
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' }
}
@@ -0,0 +1,27 @@
import type { ClaudeControlRequest } from './claude-stream-json-connection'
import type {
ClaudeAcquisitionAttempt,
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
export function handleClaudeInboundControl(input: {
sessionId: string
attempt: ClaudeAcquisitionAttempt
request: ClaudeControlRequest
emit: (event: ClaudeStructuredSessionEvent) => void
}): void {
const connection = input.attempt.connection
if (input.request.request.subtype === 'request_user_dialog') {
void connection?.respond(input.request.request_id, { behavior: 'cancelled' })
return
}
const prompt = input.attempt.prompts.register(input.request)
if (!prompt) {
void connection?.respondWithError(
input.request.request_id,
`Orca does not handle ${input.request.request.subtype}`
)
return
}
input.emit({ type: 'prompt', sessionId: input.sessionId, prompt })
}
@@ -0,0 +1,55 @@
import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution'
import type { ClaudeAuthDiagnostic } from './claude-structured-session-state'
export type ClaudeInitObservation = {
providerSessionId: string
uuid: string | null
message: Record<string, unknown>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function readClaudeFrameString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
export function readClaudeInit(message: Record<string, unknown>): ClaudeInitObservation | null {
if (message.type !== 'system' || message.subtype !== 'init') {
return null
}
const providerSessionId = readClaudeFrameString(message, 'session_id')
return providerSessionId
? {
providerSessionId,
uuid: readClaudeFrameString(message, 'uuid'),
message
}
: null
}
export function readClaudeModels(initialization: unknown): unknown[] {
return isRecord(initialization) && Array.isArray(initialization.models)
? initialization.models
: []
}
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
}
}
@@ -0,0 +1,108 @@
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,
claudeSessionIdForOrcaSession,
createClaudeStructuredLaunchResolver
} from './claude-structured-launch-resolution'
const SESSION_ID = 'orca-session-1'
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
ReturnType<typeof createClaudeStructuredLaunchResolver>
>[0]['identity']
function record(overrides: Partial<AgentSessionRecord> = {}): 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) {
return createClaudeStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude'
})
}
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(','))
})
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('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<AgentSessionRecord>))({
identity: IDENTITY
})
).rejects.toThrow(/codex session/)
await expect(
resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({
identity: IDENTITY
})
).rejects.toThrow(/CLAUDE_CONFIG_DIR/)
})
})
@@ -0,0 +1,93 @@
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'
import { getSpawnArgsForWindows } from '../win32-utils'
export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
const 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
claudeConfigDir: string
providerSessionId: string
resumeLeafUuid: string | null
resumed: boolean
}
export type ClaudeStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCommand?: () => string
}
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<ClaudeStructuredLaunch> {
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 (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 command = (deps.resolveCommand ?? resolveClaudeCommand)()
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [...BASE_ARGS, ...providerArgs])
return {
command: spawnCmd,
args: spawnArgs,
cwd: await deps.resolveWorkspacePath(record.location.workspaceId),
claudeConfigDir: record.accountHome.path,
providerSessionId,
resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null,
resumed: head?.handle.provider === 'claude'
}
}
}
@@ -0,0 +1,6 @@
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean {
return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null
}
@@ -0,0 +1,21 @@
import type { ClaudeSession } from './claude-structured-session-state'
export async function setClaudeStructuredOption(
session: ClaudeSession,
input: { key: string; value: string },
timeoutMs: number | undefined
): Promise<void> {
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 Error(`claude stream-json has no session option named ${input.key}`)
}
await session.connection.request(request.subtype, request.params, { timeoutMs })
session.options.set(input.key, input.value)
}
@@ -0,0 +1,44 @@
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'
export async function claudeProcessIdentity(
input: {
identity: AgentSessionJournalIdentity
spawnToken: string
pid: number | undefined
},
readStartTime: (pid: number) => Promise<number | null> = readProcessStartTimeMs
): Promise<AgentSessionProcessIdentity> {
if (input.pid === undefined) {
throw new Error('claude stream-json started without a pid')
}
return {
hostId: input.identity.hostId,
pid: input.pid,
processStartTimeMs: await readStartTime(input.pid),
spawnToken: input.spawnToken
}
}
export function claudeProviderHandleLink(input: {
sessionId: string
leafUuid: string | null
resumed: boolean
fence: number
linkId?: string
observedAt: number
}): AgentSessionProviderHandleLink {
return {
linkId:
input.linkId ??
`claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128),
handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid },
origin: input.resumed ? 'resumed' : 'created',
mintedAtFence: input.fence,
observedAt: input.observedAt
}
}
@@ -0,0 +1,193 @@
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<string, unknown>
suggestions: unknown[]
questionIds: readonly string[]
answers: Map<string, string>
request: ClaudeControlRequest['request']
}
type PromptBinding = {
address: string
questionId?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown>[] {
return Array.isArray(input.questions) ? input.questions.filter(isRecord) : []
}
function questionId(question: Record<string, unknown>, 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<string, ClaudePendingPrompt>()
private readonly journalBindings = new Map<string, PromptBinding>()
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<string, unknown> {
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<string, unknown> | null {
const decoded = decodeClaudeQuestionOptionId(optionId)
const selectedQuestionId =
decoded?.questionId ??
boundQuestionId ??
(prompt.questionIds.length === 1 ? prompt.questionIds[0] : null)
const answer = decoded?.answer ?? optionId
if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) {
throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`)
}
prompt.answers.set(selectedQuestionId, answer)
if (prompt.questionIds.some((id) => !prompt.answers.has(id))) {
return null
}
const answers: Record<string, string> = {}
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<string, unknown> | null {
return found.prompt.kind === 'approval'
? approvalResponse(found.prompt, optionId)
: questionResponse(found.prompt, optionId, found.questionId)
}
@@ -0,0 +1,513 @@
import { describe, expect, it } from 'vitest'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import type {
ClaudeStreamJsonConnection,
ClaudeStreamJsonConnectionHandlers,
ClaudeStreamJsonLaunch,
openClaudeStreamJsonConnection
} from './claude-stream-json-connection'
import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity'
import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies'
import {
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<string, unknown> | undefined) => unknown
type FakeConnection = Omit<ClaudeStreamJsonConnection, 'closed'> & {
closed: boolean
launch: ClaudeStreamJsonLaunch
handlers: ClaudeStreamJsonConnectionHandlers
calls: { subtype: string; params?: Record<string, unknown> }[]
sent: Record<string, unknown>[]
replies: { requestId: string; response?: unknown; error?: string }[]
closeCount: number
}
function fakeClaude(
options: {
initSessionId?: string
initUuid?: string
initModel?: string
initEffort?: string
exitBeforeInit?: string
settings?: unknown
replayUuid?: string | null
routes?: Record<string, Route>
} = {}
): {
connections: FakeConnection[]
openConnection: typeof openClaudeStreamJsonConnection
routes: Record<string, Route>
} {
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: [] }
}
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' }] }
}
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
}
}
connections.push(connection)
return connection
}) as typeof openClaudeStreamJsonConnection
return { connections, openConnection, routes }
}
function adapterFor(
claude: ReturnType<typeof fakeClaude>,
launch: Partial<ClaudeStructuredLaunch> = {},
events: ClaudeStructuredSessionEvent[] = [],
persistedHandles: unknown[] = []
): 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,
dispatchAckTimeoutMs: 10,
persistHandle: async (handle) => {
persistedHandles.push(handle)
}
})
}
async function acquired(
claude: ReturnType<typeof fakeClaude>,
launch: Partial<ClaudeStructuredLaunch> = {},
events: ClaudeStructuredSessionEvent[] = []
): Promise<ClaudeStructuredSessionAdapter> {
const adapter = adapterFor(claude, launch, events)
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
return adapter
}
describe('ClaudeStructuredSessionAdapter.acquire', () => {
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).toEqual({
linkId: `claude-7-${PROVIDER_SESSION_ID}-init-uuid`,
handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: 'init-uuid' },
origin: 'created',
mintedAtFence: 7,
observedAt: 1_700_000_000_500
})
expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } })
})
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: 'init-uuid'
})
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)
})
})
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 adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
expect(claude.connections[0].calls.slice(-2)).toEqual([
{ subtype: 'interrupt', params: {} },
{ subtype: 'set_model', params: { model: 'sonnet' } }
])
claude.routes.interrupt = () => {
throw new Error('not running')
}
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 })
).resolves.toEqual({ cancelled: false })
})
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('persists the last observed leaf before graceful close', async () => {
const claude = fakeClaude()
const events: ClaudeStructuredSessionEvent[] = []
const persistedHandles: unknown[] = []
const adapter = adapterFor(claude, {}, 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: 'final-leaf'
})
await adapter.closeSession('session-1')
expect(persistedHandles).toEqual([
{
sessionId: 'session-1',
providerSessionId: PROVIDER_SESSION_ID,
leafUuid: 'final-leaf',
fence: 7
}
])
expect(events.at(-2)).toEqual({
type: 'handle',
sessionId: 'session-1',
providerSessionId: PROVIDER_SESSION_ID,
leafUuid: 'final-leaf',
fence: 7
})
expect(claude.connections[0].closeCount).toBe(1)
})
})
@@ -0,0 +1,317 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
AgentSessionAcquisition,
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
openClaudeStreamJsonConnection,
type ClaudeControlRequest
} from './claude-stream-json-connection'
import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions'
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
import { handleClaudeInboundControl } from './claude-structured-inbound-control'
import {
claudeAuthDiagnostic,
readClaudeFrameString,
readClaudeInit,
readClaudeModels,
type ClaudeInitObservation
} from './claude-structured-init-proof'
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity'
import { setClaudeStructuredOption } from './claude-structured-options'
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
import { createClaudeSessionPublication } from './claude-structured-session-publication'
import {
cancelClaudeAcquisitionAttempt,
ClaudeAcquisitionRegistry,
type ClaudeAcquisitionAttempt,
type ClaudeSession,
type ClaudeStructuredSessionAdapterDeps,
type ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
import {
closeClaudePublishedSession,
settleClaudeDispatchWaiters
} from './claude-structured-session-close'
export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
export type {
ClaudeAuthDiagnostic,
ClaudeStructuredSessionAdapterDeps,
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
const INIT_TIMEOUT_MS = 30_000
const DISPATCH_ACK_TIMEOUT_MS = 10_000
export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, ClaudeSession>()
private readonly acquisitions = new ClaudeAcquisitionRegistry()
constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) {}
supportsLocation = supportsClaudeStructuredLocation
async acquire(input: {
identity: AgentSessionJournalIdentity
fence: number
spawnToken: string
events?: StructuredAgentSessionEventSink
}): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const prompts = new ClaudePromptRegistry()
const { previous, attempt } = this.acquisitions.start(sessionId, prompts)
let liveSession: ClaudeSession | null = null
let observedLeafUuid: string | null = null
let settleInit = (_init: ClaudeInitObservation): void => {}
let rejectInit = (_error: Error): void => {}
const initialized = new Promise<ClaudeInitObservation>((resolve, reject) => {
settleInit = resolve
rejectInit = reject
})
const initTimer = setTimeout(
() => rejectInit(new Error(`claude session ${sessionId} did not emit system/init`)),
INIT_TIMEOUT_MS
)
initTimer.unref?.()
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (init) {
settleInit(init)
}
observedLeafUuid = readClaudeFrameString(message, 'uuid') ?? observedLeafUuid
if (liveSession) {
liveSession.leafUuid = observedLeafUuid
resolveClaudeReplayWaiter(liveSession, message)
}
this.deliver(attempt, sessionId, () =>
this.emit(liveSession, input.events, { type: 'message', sessionId, message })
)
}
const onControlRequest = (request: ClaudeControlRequest): void => {
handleClaudeInboundControl({
sessionId,
attempt,
request,
emit: (event) =>
this.deliver(attempt, sessionId, () => this.emit(liveSession, input.events, event))
})
}
try {
await cancelClaudeAcquisitionAttempt(previous)
this.acquisitions.assertCurrent(sessionId, attempt)
await this.closePublishedSession(sessionId)
this.acquisitions.assertCurrent(sessionId, attempt)
const launch = await this.deps.resolveLaunch({ identity: input.identity })
observedLeafUuid = 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: {
[CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken,
CLAUDE_CONFIG_DIR: launch.claudeConfigDir
}
},
{
onMessage,
onControlRequest,
onControlCancelRequest: ({ request_id: requestId }) => {
const prompt = prompts.cancel(requestId)
if (prompt) {
this.deliver(attempt, sessionId, () =>
this.emit(liveSession, input.events, {
type: 'prompt-cancelled',
sessionId,
promptKey: prompt.promptKey
})
)
}
},
onExit: (error) => {
if (!attempt.published) {
rejectInit(error)
}
this.handleExit(sessionId, attempt, error)
}
}
)
attempt.connection = connection
this.acquisitions.assertCurrent(sessionId, attempt)
const [initialization, init] = await Promise.all([
connection.request(
'initialize',
{ supportedDialogKinds: [] },
{ timeoutMs: this.deps.requestTimeoutMs }
),
initialized
])
const models = readClaudeModels(initialization)
this.deliver(attempt, sessionId, () =>
this.emit(liveSession, input.events, { type: 'options', sessionId, models })
)
clearTimeout(initTimer)
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)
this.deliver(attempt, sessionId, () =>
this.emit(liveSession, input.events, {
type: 'auth-diagnostic',
sessionId,
diagnostic: claudeAuthDiagnostic(init, settings)
})
)
observedLeafUuid = init.uuid ?? observedLeafUuid
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: observedLeafUuid,
fence: input.fence,
resumed: launch.resumed,
prompts,
events: input.events,
process,
...(this.deps.mintLinkId ? { linkId: this.deps.mintLinkId() } : {}),
observedAt: this.deps.now?.() ?? Date.now()
})
const acquired: AgentSessionAcquisition = publication.acquisition
liveSession = publication.session
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) {
clearTimeout(initTimer)
this.acquisitions.deleteIfCurrent(sessionId, attempt)
if (this.sessions.get(sessionId)?.connection !== attempt.connection) {
prompts.clear()
await attempt.connection?.close()
}
throw error
} finally {
attempt.finish()
}
}
private 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()
}
}
private 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)
settleClaudeDispatchWaiters(session)
session.prompts.clear()
this.emit(session, session.events, { type: 'ended', sessionId, reason: error.message })
}
private emit(
_session: ClaudeSession | null,
_events: StructuredAgentSessionEventSink | undefined,
event: ClaudeStructuredSessionEvent
): void {
this.deps.onEvent?.(event)
}
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<void> {
return this.closeSession(input.sessionId)
}
async closeSession(sessionId: string): Promise<void> {
const attempt = this.acquisitions.get(sessionId)
if (attempt) {
attempt.cancelled = true
await attempt.connection?.close()
await attempt.finished
}
await this.closePublishedSession(sessionId)
}
private async closePublishedSession(sessionId: string): Promise<void> {
await closeClaudePublishedSession({
sessions: this.sessions,
sessionId,
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
})
}
async closeAll(): Promise<void> {
this.acquisitions.close()
while (this.sessions.size > 0 || this.acquisitions.size > 0) {
const ids = new Set([...this.sessions.keys(), ...this.acquisitions.sessionIds()])
await Promise.all([...ids].map((sessionId) => this.closeSession(sessionId)))
}
}
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
}
}
@@ -0,0 +1,66 @@
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 async function closeClaudePublishedSession(input: {
sessions: Map<string, ClaudeSession>
sessionId: string
persistHandle?: (handle: {
sessionId: string
providerSessionId: string
leafUuid: string | null
fence: number
}) => Promise<void>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
}): Promise<void> {
const session = input.sessions.get(input.sessionId)
if (!session) {
return
}
input.sessions.delete(input.sessionId)
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
})
)
)
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 {
input.onEvent?.({
type: 'ended',
sessionId: input.sessionId,
reason: 'claude session closed'
})
await session.connection.close()
}
if (persistenceError) {
throw persistenceError
}
}
@@ -0,0 +1,108 @@
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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown>): 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<string, unknown> => row !== null)
: []
const defaultRow = rows.find((row) => text(row.value) === 'default')
const defaultResolvedModel = text(defaultRow?.resolvedModel)
const seen = new Set<string>()
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<AgentSessionOptionsResult> {
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(({ resolvedModel: _, ...entry }) => entry),
current: { model, ...(effort ? { effort } : {}) }
}
}
@@ -0,0 +1,48 @@
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 { ClaudeSession } from './claude-structured-session-state'
export function createClaudeSessionPublication(input: {
connection: ClaudeSession['connection']
init: ClaudeInitObservation
leafUuid: string | null
fence: number
resumed: boolean
prompts: ClaudePromptRegistry
events: ClaudeSession['events']
process: AgentSessionAcquisition['process']
linkId?: string
observedAt: number
}): { 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(),
reportedOptions: {
...(model ? { model } : {}),
...(effort ? { effort } : {})
},
events: input.events
}
}
}
@@ -0,0 +1,155 @@
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 { 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<string, 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<ClaudeStructuredLaunch>
onEvent?: (event: ClaudeStructuredSessionEvent) => void
openConnection?: typeof openClaudeStreamJsonConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
now?: () => number
requestTimeoutMs?: number
dispatchAckTimeoutMs?: number
persistHandle?: (input: {
sessionId: string
providerSessionId: string
leafUuid: string | null
fence: number
}) => Promise<void>
}
export type ClaudeDispatchWaiter = {
resolve: (uuid: string | null) => void
timer: ReturnType<typeof setTimeout>
}
export type ClaudeSession = {
connection: ClaudeStreamJsonConnection
providerSessionId: string
leafUuid: string | null
fence: number
prompts: ClaudePromptRegistry
dispatchWaiters: ClaudeDispatchWaiter[]
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
events: StructuredAgentSessionEventSink | undefined
}
export type ClaudeAcquisitionAttempt = {
connection: ClaudeStreamJsonConnection | null
prompts: ClaudePromptRegistry
buffered: (() => void)[]
published: boolean
cancelled: boolean
finished: Promise<void>
finish: () => void
}
export function createClaudeAcquisitionAttempt(
prompts: ClaudePromptRegistry
): ClaudeAcquisitionAttempt {
let finish = (): void => {}
const finished = new Promise<void>((resolve) => {
finish = resolve
})
return {
connection: null,
prompts,
buffered: [],
published: false,
cancelled: false,
finished,
finish
}
}
export class ClaudeAcquisitionRegistry {
private readonly attempts = new Map<string, ClaudeAcquisitionAttempt>()
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<string> {
return this.attempts.keys()
}
close(): void {
this.closing = true
}
}
export async function cancelClaudeAcquisitionAttempt(
attempt: ClaudeAcquisitionAttempt | undefined
): Promise<void> {
if (!attempt) {
return
}
attempt.cancelled = true
await attempt.connection?.close()
await attempt.finished
}