1315 lines · 42.5 KB · TypeScript
RawDownload
/** * 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<unknown>;  readonly messageCompleted: boolean;  readonly emittedTextDelta: boolean;  readonly fallbackAssistantText: string;} interface PendingApproval {  readonly requestKind: "command" | "file-change";  readonly detail?: string;  readonly suggestions?: ReadonlyArray<PermissionUpdate>;  readonly decision: Deferred.Deferred<ProviderApprovalDecision>;} interface ToolInFlight {  readonly itemId: string;  readonly toolKind: ProviderRuntimeToolKind;  readonly title: string;  readonly detail?: string;} interface ClaudeSessionContext {  session: ProviderSession;  readonly promptQueue: Queue.Queue<PromptQueueItem>;  readonly query: ClaudeQueryRuntime;  readonly startedAt: string;  resumeSessionId: string | undefined;  readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;  readonly turns: Array<{    id: ReturnType<typeof ProviderTurnId.makeUnsafe>;    items: Array<unknown>;  }>;  readonly inFlightTools: Map<number, ToolInFlight>;  turnState: ClaudeTurnState | undefined;  lastAssistantUuid: string | undefined;  lastThreadStartedId: ProviderThreadId | undefined;  stopped: boolean;} interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {  readonly interrupt: () => Promise<void>;  readonly setModel: (model?: string) => Promise<void>;  readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;  readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;  readonly close: () => void;} export interface ClaudeCodeAdapterLiveOptions {  readonly createQuery?: (input: {    readonly prompt: AsyncIterable<SDKUserMessage>;    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, unknown>): 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<typeof ProviderSessionId.makeUnsafe>,  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<typeof ProviderSessionId.makeUnsafe>,  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<SDKUserMessage>;        readonly options: ClaudeQueryOptions;      }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);     const sessions = new Map<ProviderSessionId, ClaudeSessionContext>();    const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();     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<void> =>      Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);     const logNativeSdkMessage = (      context: ClaudeSessionContext,      message: SDKMessage,    ): Effect.Effect<void> =>      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<typeof ProviderThreadId.makeUnsafe>;      turns: ReadonlyArray<{        id: ReturnType<typeof ProviderTurnId.makeUnsafe>;        items: ReadonlyArray<unknown>;      }>;    }> =>      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<void> =>      Effect.gen(function* () {        const threadId = context.session.threadId;        if (!threadId) return;         const resumeCursor = {          threadId,          ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),          ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),

Lines 1–500 of 1315.