/** * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. * * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic * provider adapter contract and emits canonical runtime events. * * @module ClaudeCodeAdapterLive */ import { type CanUseTool, query, type Options as ClaudeQueryOptions, type PermissionMode, type PermissionResult, type PermissionUpdate, type SDKMessage, type SDKResultMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, EventId, type ProviderApprovalDecision, ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeToolKind, type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, ProviderSessionId, type ProviderSession, ProviderThreadId, ProviderTurnId, } from "@t3tools/contracts"; import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "claudeCode" as const; type PromptQueueItem = | { readonly type: "message"; readonly message: SDKUserMessage; } | { readonly type: "terminate"; }; interface ClaudeResumeState { readonly threadId?: ProviderThreadId; readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; } interface ClaudeTurnState { readonly turnId: ProviderTurnId; readonly assistantItemId: string; readonly startedAt: string; readonly items: Array; readonly messageCompleted: boolean; readonly emittedTextDelta: boolean; readonly fallbackAssistantText: string; } interface PendingApproval { readonly requestKind: "command" | "file-change"; readonly detail?: string; readonly suggestions?: ReadonlyArray; readonly decision: Deferred.Deferred; } interface ToolInFlight { readonly itemId: string; readonly toolKind: ProviderRuntimeToolKind; readonly title: string; readonly detail?: string; } interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; readonly startedAt: string; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly turns: Array<{ id: ReturnType; items: Array; }>; readonly inFlightTools: Map; turnState: ClaudeTurnState | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: ProviderThreadId | undefined; stopped: boolean; } interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; readonly close: () => void; } export interface ClaudeCodeAdapterLiveOptions { readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; } function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( value, ); } function isSyntheticClaudeThreadId(value: string): boolean { return value.startsWith("claude-thread-"); } function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; } return fallback; } function toPermissionMode(value: unknown): PermissionMode | undefined { switch (value) { case "default": case "acceptEdits": case "bypassPermissions": case "plan": case "dontAsk": return value; default: return undefined; } } function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { if (!resumeCursor || typeof resumeCursor !== "object") { return undefined; } const cursor = resumeCursor as { threadId?: unknown; resume?: unknown; sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; const threadId = threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) ? threadIdCandidate : undefined; const resumeCandidate = typeof cursor.resume === "string" ? cursor.resume : typeof cursor.sessionId === "string" ? cursor.sessionId : undefined; const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; const resumeSessionAt = typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; return { ...(threadId ? { threadId: ProviderThreadId.makeUnsafe(threadId) } : {}), ...(resume ? { resume } : {}), ...(resumeSessionAt ? { resumeSessionAt } : {}), ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), }; } function classifyToolKind(toolName: string): ProviderRuntimeToolKind { const normalized = toolName.toLowerCase(); if ( normalized.includes("bash") || normalized.includes("command") || normalized.includes("shell") || normalized.includes("terminal") ) { return "command"; } if ( normalized.includes("edit") || normalized.includes("write") || normalized.includes("file") || normalized.includes("patch") || normalized.includes("replace") || normalized.includes("create") || normalized.includes("delete") ) { return "file-change"; } return "other"; } function classifyRequestKind(toolName: string): "command" | "file-change" { return classifyToolKind(toolName) === "command" ? "command" : "file-change"; } function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; if (command && command.trim().length > 0) { return `${toolName}: ${command.trim().slice(0, 400)}`; } const serialized = JSON.stringify(input); if (serialized.length <= 400) { return `${toolName}: ${serialized}`; } return `${toolName}: ${serialized.slice(0, 397)}...`; } function titleForTool(kind: ProviderRuntimeToolKind): string { switch (kind) { case "command": return "Command run"; case "file-change": return "File change"; case "other": return "Tool call"; } } function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { const fragments: string[] = []; if (input.input && input.input.trim().length > 0) { fragments.push(input.input.trim()); } for (const attachment of input.attachments ?? []) { if (attachment.type === "image") { fragments.push( `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, ); } } const text = fragments.join("\n\n"); return { type: "user", session_id: "", parent_tool_use_id: null, message: { role: "user", content: [{ type: "text", text }], }, } as SDKUserMessage; } function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { if (result.subtype === "success") { return "completed"; } const errors = result.errors.join(" ").toLowerCase(); if (errors.includes("interrupt")) { return "interrupted"; } if (errors.includes("cancel")) { return "cancelled"; } return "failed"; } function extractAssistantText(message: SDKMessage): string { if (message.type !== "assistant") { return ""; } const content = (message.message as { content?: unknown } | undefined)?.content; if (!Array.isArray(content)) { return ""; } const fragments: string[] = []; for (const block of content) { if (!block || typeof block !== "object") { continue; } const candidate = block as { type?: unknown; text?: unknown }; if (candidate.type === "text" && typeof candidate.text === "string" && candidate.text.length > 0) { fragments.push(candidate.text); } } return fragments.join(""); } function toSessionError( sessionId: ReturnType, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("not found")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, sessionId, cause, }); } if (normalized.includes("closed")) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, sessionId, cause, }); } return undefined; } function toRequestError( sessionId: ReturnType, method: string, cause: unknown, ): ProviderAdapterError { const sessionError = toSessionError(sessionId, cause); if (sessionError) { return sessionError; } return new ProviderAdapterRequestError({ provider: PROVIDER, method, detail: toMessage(cause, `${method} failed`), cause, }); } function sdkMessageType(value: unknown): string | undefined { if (!value || typeof value !== "object") { return undefined; } const record = value as { type?: unknown }; return typeof record.type === "string" ? record.type : undefined; } function sdkMessageSubtype(value: unknown): string | undefined { if (!value || typeof value !== "object") { return undefined; } const record = value as { subtype?: unknown }; return typeof record.subtype === "string" ? record.subtype : undefined; } function sdkNativeMethod(message: SDKMessage): string { const subtype = sdkMessageSubtype(message); if (subtype) { return `claude/${message.type}/${subtype}`; } if (message.type === "stream_event") { const streamType = sdkMessageType(message.event); if (streamType) { const deltaType = streamType === "content_block_delta" ? sdkMessageType((message.event as { delta?: unknown }).delta) : undefined; if (deltaType) { return `claude/${message.type}/${streamType}/${deltaType}`; } return `claude/${message.type}/${streamType}`; } } return `claude/${message.type}`; } function sdkNativeItemId(message: SDKMessage): string | undefined { if (message.type === "assistant") { const maybeId = (message.message as { id?: unknown }).id; if (typeof maybeId === "string") { return maybeId; } return undefined; } if (message.type === "stream_event") { const event = message.event as { type?: unknown; content_block?: { id?: unknown }; }; if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { return event.content_block.id; } } return undefined; } function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return Effect.gen(function* () { const nativeEventLogger = options?.nativeEventLogPath !== undefined ? makeEventNdjsonLogger(options.nativeEventLogPath) : undefined; const createQuery = options?.createQuery ?? ((input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); const logNativeSdkMessage = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.sync(() => { if (!nativeEventLogger) { return; } const observedAt = new Date().toISOString(); const itemId = sdkNativeItemId(message); nativeEventLogger.write({ observedAt, event: { id: "uuid" in message && typeof message.uuid === "string" ? message.uuid : crypto.randomUUID(), kind: "notification", provider: PROVIDER, sessionId: context.session.sessionId, createdAt: observedAt, method: sdkNativeMethod(message), ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), payload: message, }, }); }); const snapshotThread = ( context: ClaudeSessionContext, ): Effect.Effect<{ threadId: ReturnType; turns: ReadonlyArray<{ id: ReturnType; items: ReadonlyArray; }>; }> => Effect.gen(function* () { const threadId = context.session.threadId; if (!threadId) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "readThread", issue: "Session thread id is not initialized yet.", }); } return { threadId, turns: context.turns.map((turn) => ({ id: turn.id, items: [...turn.items], })), }; }); const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => Effect.gen(function* () { const threadId = context.session.threadId; if (!threadId) return; const resumeCursor = { threadId, ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), turnCount: context.turns.length, }; context.session = { ...context.session, resumeCursor, updatedAt: yield* nowIso, }; }); const ensureThreadId = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (typeof message.session_id !== "string" || message.session_id.length === 0) { return; } const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id); context.resumeSessionId = message.session_id; const changed = context.session.threadId !== nextThreadId; if (changed) { const updatedAt = yield* nowIso; context.session = { ...context.session, threadId: nextThreadId, updatedAt, }; yield* updateResumeCursor(context); } if (context.lastThreadStartedId !== nextThreadId) { context.lastThreadStartedId = nextThreadId; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "thread.started", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, threadId: nextThreadId, }); } }); const emitRuntimeError = ( context: ClaudeSessionContext, message: string, cause?: unknown, ): Effect.Effect => Effect.gen(function* () { if (cause !== undefined) { void cause; } const turnState = context.turnState; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "runtime.error", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(turnState ? { turnId: turnState.turnId } : {}), message, }); }); const completeTurn = ( context: ClaudeSessionContext, status: ProviderRuntimeTurnStatus, errorMessage?: string, ): Effect.Effect => Effect.gen(function* () { const turnState = context.turnState; if (!turnState) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), status, ...(errorMessage ? { errorMessage } : {}), }); return; } if (!turnState.messageCompleted) { if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { const deltaStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "message.delta", eventId: deltaStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: deltaStamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, itemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), delta: turnState.fallbackAssistantText, }); } const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "message.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, itemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, }); } context.turns.push({ id: turnState.turnId, items: [...turnState.items], }); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, status, ...(errorMessage ? { errorMessage } : {}), }); const updatedAt = yield* nowIso; context.turnState = undefined; context.session = { ...context.session, status: "ready", activeTurnId: undefined, updatedAt, ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), }; yield* updateResumeCursor(context); }); const handleStreamEvent = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "stream_event") { return; } const { event } = message; if (event.type === "content_block_delta") { if ( event.delta.type === "text_delta" && event.delta.text.length > 0 && context.turnState ) { if (!context.turnState.emittedTextDelta) { context.turnState = { ...context.turnState, emittedTextDelta: true, }; } const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "message.delta", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: context.turnState.turnId, itemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), delta: event.delta.text, }); } return; } if (event.type === "content_block_start") { const { index, content_block: block } = event; if ( block.type !== "tool_use" && block.type !== "server_tool_use" && block.type !== "mcp_tool_use" ) { return; } const toolName = block.name; const toolKind = classifyToolKind(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) : {}; const itemId = block.id; const detail = summarizeToolRequest(toolName, toolInput); const tool: ToolInFlight = { itemId, toolKind, title: titleForTool(toolKind), detail, }; context.inFlightTools.set(index, tool); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "tool.started", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), itemId: ProviderItemId.makeUnsafe(tool.itemId), toolKind: tool.toolKind, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), }); return; } if (event.type === "content_block_stop") { const { index } = event; const tool = context.inFlightTools.get(index); if (!tool) { return; } context.inFlightTools.delete(index); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "tool.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), itemId: ProviderItemId.makeUnsafe(tool.itemId), toolKind: tool.toolKind, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), }); } }); const handleAssistantMessage = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "assistant") { return; } if (context.turnState) { context.turnState.items.push(message.message); const fallbackAssistantText = extractAssistantText(message); if ( fallbackAssistantText.length > 0 && fallbackAssistantText !== context.turnState.fallbackAssistantText ) { context.turnState = { ...context.turnState, fallbackAssistantText, }; } } context.lastAssistantUuid = message.uuid; yield* updateResumeCursor(context); }); const handleResultMessage = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "result") { return; } const status = turnStatusFromResult(message); const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); } yield* completeTurn(context, status, errorMessage); }); const handleSdkMessage = ( context: ClaudeSessionContext, message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { yield* logNativeSdkMessage(context, message); yield* ensureThreadId(context, message); switch (message.type) { case "stream_event": yield* handleStreamEvent(context, message); return; case "assistant": yield* handleAssistantMessage(context, message); return; case "result": yield* handleResultMessage(context, message); return; default: return; } }); const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( Stream.takeWhile(() => !context.stopped), Stream.runForEach((message) => handleSdkMessage(context, message)), Effect.catchCause((cause) => Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause) || context.stopped) { return; } const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); yield* emitRuntimeError(context, message, cause); yield* completeTurn(context, "failed", message); }), ), ); const stopSessionInternal = ( context: ClaudeSessionContext, options?: { readonly emitExitEvent?: boolean }, ): Effect.Effect => Effect.gen(function* () { if (context.stopped) return; context.stopped = true; for (const [requestId, pending] of context.pendingApprovals) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "approval.resolved", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, requestKind: pending.requestKind, decision: "cancel", }); } context.pendingApprovals.clear(); if (context.turnState) { yield* completeTurn(context, "interrupted", "Session stopped."); } yield* Queue.shutdown(context.promptQueue); context.query.close(); const updatedAt = yield* nowIso; context.session = { ...context.session, status: "closed", activeTurnId: undefined, updatedAt, }; if (options?.emitExitEvent !== false) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), message: "Session stopped", }); } sessions.delete(context.session.sessionId); }); const requireSession = ( sessionId: ReturnType, ): Effect.Effect => { const context = sessions.get(sessionId); if (!context) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, sessionId, }), ); } if (context.stopped || context.session.status === "closed") { return Effect.fail( new ProviderAdapterSessionClosedError({ provider: PROVIDER, sessionId, }), ); } return Effect.succeed(context); }; const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => Effect.gen(function* () { if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, }); } const startedAt = yield* nowIso; const sessionId = ProviderSessionId.makeUnsafe( `claude-session-${yield* Random.nextUUIDv4}`, ); const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = resumeState?.threadId; const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( Stream.filter((item) => item.type === "message"), Stream.map((item) => item.message), Stream.toAsyncIterable, ); const pendingApprovals = new Map(); const inFlightTools = new Map(); const contextRef = yield* Ref.make(undefined); const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => Effect.runPromise( Effect.gen(function* () { const context = yield* Ref.get(contextRef); if (!context) { return { behavior: "deny", message: "Claude session context is unavailable.", } satisfies PermissionResult; } const approvalPolicy = input.approvalPolicy ?? "never"; if (approvalPolicy === "never") { return { behavior: "allow", updatedInput: toolInput, } satisfies PermissionResult; } const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); const requestKind = classifyRequestKind(toolName); const detail = summarizeToolRequest(toolName, toolInput); const decisionDeferred = yield* Deferred.make(); const pendingApproval: PendingApproval = { requestKind, detail, decision: decisionDeferred, ...(callbackOptions.suggestions ? { suggestions: callbackOptions.suggestions } : {}), }; const requestedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "approval.requested", eventId: requestedStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: requestedStamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, requestKind, detail, }); pendingApprovals.set(requestId, pendingApproval); const onAbort = () => { if (!pendingApprovals.has(requestId)) { return; } pendingApprovals.delete(requestId); Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); }; callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); const resolvedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "approval.resolved", eventId: resolvedStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: resolvedStamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, requestKind, decision, }); if (decision === "accept" || decision === "acceptForSession") { return { behavior: "allow", updatedInput: toolInput, ...(decision === "acceptForSession" && pendingApproval.suggestions ? { updatedPermissions: [...pendingApproval.suggestions] } : {}), } satisfies PermissionResult; } return { behavior: "deny", message: decision === "cancel" ? "User cancelled tool execution." : "User declined tool execution.", } satisfies PermissionResult; }), ); const providerOptions = input.providerOptions?.claudeCode; const permissionMode = toPermissionMode(providerOptions?.permissionMode) ?? (input.approvalPolicy === "never" ? "bypassPermissions" : undefined); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), ...(providerOptions?.binaryPath ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), ...(providerOptions?.maxThinkingTokens !== undefined ? { maxThinkingTokens: providerOptions.maxThinkingTokens } : {}), ...(resumeState?.resume ? { resume: resumeState.resume } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), includePartialMessages: true, canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), }; const queryRuntime = yield* Effect.try({ try: () => createQuery({ prompt, options: queryOptions, }), catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, sessionId, detail: toMessage(cause, "Failed to start Claude runtime session."), cause, }), }); const session: ProviderSession = { sessionId, provider: PROVIDER, status: "ready", ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), ...(threadId ? { threadId } : {}), resumeCursor: { ...(threadId ? { threadId } : {}), ...(resumeState?.resume ? { resume: resumeState.resume } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), turnCount: resumeState?.turnCount ?? 0, }, createdAt: startedAt, updatedAt: startedAt, }; const context: ClaudeSessionContext = { session, promptQueue, query: queryRuntime, startedAt, resumeSessionId: resumeState?.resume, pendingApprovals, turns: [], inFlightTools, turnState: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, }; yield* Ref.set(contextRef, context); sessions.set(sessionId, context); const sessionStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.started", eventId: sessionStartedStamp.eventId, provider: PROVIDER, sessionId, createdAt: sessionStartedStamp.createdAt, ...(threadId ? { threadId } : {}), }); Effect.runFork(runSdkStream(context)); return { ...session, }; }); const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const context = yield* requireSession(input.sessionId); if (context.turnState) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, }); } if (input.model) { yield* Effect.tryPromise({ try: () => context.query.setModel(input.model), catch: (cause) => toRequestError(input.sessionId, "turn/setModel", cause), }); } const turnId = ProviderTurnId.makeUnsafe(`claude-turn-${yield* Random.nextUUIDv4}`); const turnState: ClaudeTurnState = { turnId, assistantItemId: `claude-message-${yield* Random.nextUUIDv4}`, startedAt: yield* nowIso, items: [], messageCompleted: false, emittedTextDelta: false, fallbackAssistantText: "", }; const updatedAt = yield* nowIso; context.turnState = turnState; context.session = { ...context.session, status: "running", activeTurnId: turnId, updatedAt, }; const turnStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.started", eventId: turnStartedStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, createdAt: turnStartedStamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId, }); const message = buildUserMessage(input); yield* Queue.offer(context.promptQueue, { type: "message", message, }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause))); return { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId, ...(context.session.resumeCursor !== undefined ? { resumeCursor: context.session.resumeCursor } : {}), }; }); const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, _turnId) => Effect.gen(function* () { const context = yield* requireSession(sessionId); yield* Effect.tryPromise({ try: () => context.query.interrupt(), catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), }); }); const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) => Effect.gen(function* () { const context = yield* requireSession(sessionId); return yield* snapshotThread(context); }); const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => Effect.gen(function* () { const context = yield* requireSession(sessionId); const nextLength = Math.max(0, context.turns.length - numTurns); context.turns.splice(nextLength); yield* updateResumeCursor(context); return yield* snapshotThread(context); }); const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( sessionId, requestId, decision, ) => Effect.gen(function* () { const context = yield* requireSession(sessionId); const pending = context.pendingApprovals.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "item/requestApproval/decision", detail: `Unknown pending approval request: ${requestId}`, }); } context.pendingApprovals.delete(requestId); yield* Deferred.succeed(pending.decision, decision); }); const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) => Effect.gen(function* () { const context = yield* requireSession(sessionId); yield* stopSessionInternal(context, { emitExitEvent: true, }); }); const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) => Effect.sync(() => { const context = sessions.get(sessionId); return context !== undefined && !context.stopped; }); const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => Effect.forEach( sessions, ([, context]) => stopSessionInternal(context, { emitExitEvent: true, }), { discard: true }, ); yield* Effect.addFinalizer(() => Effect.forEach( sessions, ([, context]) => stopSessionInternal(context, { emitExitEvent: false, }), { discard: true }, ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), ); return { provider: PROVIDER, startSession, sendTurn, interruptTurn, readThread, rollbackThread, respondToRequest, stopSession, listSessions, hasSession, stopAll, streamEvents: Stream.fromQueue(runtimeEventQueue), } satisfies ClaudeCodeAdapterShape; }); } export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); }