4644 lines · 154.2 KB · TypeScript
RawDownload
/** * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. * * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic * provider adapter contract and emits canonical runtime events. * * @module ClaudeAdapterLive */import {  type CanUseTool,  query,  type Options as ClaudeQueryOptions,  type PermissionMode,  type PermissionResult,  type PermissionUpdate,  type SDKMessage,  type SDKControlGetContextUsageResponse,  type SDKResultMessage,  type SettingSource,  type SDKUserMessage,  type ModelUsage,} from "@anthropic-ai/claude-agent-sdk";import { parseCliArgs } from "@t3tools/shared/cliArgs";import {  ApprovalRequestId,  type CanonicalItemType,  type CanonicalRequestType,  type ClaudeSettings,  EventId,  type ProviderApprovalDecision,  ProviderDriverKind,  ProviderInstanceId,  type ModelSelection,  ProviderItemId,  type ProviderRuntimeEvent,  type ProviderRuntimeTurnStatus,  type ProviderSendTurnInput,  type ProviderSession,  type ThreadTokenUsageSnapshot,  type ProviderUserInputAnswers,  type RuntimeContentStreamKind,  RuntimeItemId,  RuntimeRequestId,  RuntimeTaskId,  type RuntimeTaskStatus,  type RuntimeTaskUsage,  type TaskAgentLinkage,  type TaskRunHandles,  ThreadId,  TurnId,  type UserInputQuestion,} from "@t3tools/contracts";import {  applyClaudePromptEffortPrefix,  getModelSelectionBooleanOptionValue,  getModelSelectionStringOptionValue,  getProviderOptionDescriptors,  resolvePromptInjectedEffort,} from "@t3tools/shared/model";import * as Cause from "effect/Cause";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Fiber from "effect/Fiber";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Queue from "effect/Queue";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import * as McpProviderSession from "../../mcp/McpProviderSession.ts";import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";import {  getClaudeModelCapabilities,  isClaudeUltracodeEffort,  normalizeClaudeCliEffort,  resolveClaudeApiModelId,  resolveClaudeContextWindow,  resolveClaudeEffort,} from "./ClaudeProvider.ts";import {  ProviderAdapterProcessError,  ProviderAdapterRequestError,  ProviderAdapterSessionClosedError,  ProviderAdapterSessionNotFoundError,  ProviderAdapterValidationError,  type ProviderAdapterError,} from "../Errors.ts";import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("claudeAgent");type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;type ClaudeToolResultStreamKind = Extract<  RuntimeContentStreamKind,  "command_output" | "file_change_output">;type ClaudeSdkEffort = NonNullable<ClaudeQueryOptions["effort"]>; function encodeJsonStringForDiagnostics(input: unknown): string | undefined {  const result = encodeUnknownJsonStringExit(input);  return Exit.isSuccess(result) ? result.value : undefined;} type PromptQueueItem =  | {      readonly type: "message";      readonly message: SDKUserMessage;    }  | {      readonly type: "terminate";    }; interface ClaudeResumeState {  readonly threadId?: ThreadId;  readonly resume?: string;  readonly resumeSessionAt?: string;  readonly turnCount?: number;} interface ClaudeTurnState {  readonly turnId: TurnId;  readonly startedAt: string;  /**   * True for turns auto-started by assistant output arriving without an   * active turn (background agent/subagent responses between user prompts).   * Synthetic turns are auto-closed by the next sendTurn; real turns are   * steered instead (the queued message continues the same turn).   */  readonly synthetic?: boolean;  readonly items: Array<unknown>;  readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;  readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;  readonly capturedProposedPlanKeys: Set<string>;  nextSyntheticAssistantBlockIndex: number;} interface AssistantTextBlockState {  readonly itemId: string;  readonly blockIndex: number;  emittedTextDelta: boolean;  fallbackText: string;  streamClosed: boolean;  completionEmitted: boolean;} interface PendingApproval {  readonly requestType: CanonicalRequestType;  readonly detail?: string;  readonly suggestions?: ReadonlyArray<PermissionUpdate>;  readonly decision: Deferred.Deferred<ProviderApprovalDecision>;} /** * Permission updates applied for an "Always allow this session" decision. * * Claude Code's suggestions are reused when present but rescoped to * `destination: "session"` — echoing them verbatim would persist the * session-only choice as a permanent rule (suggestions typically target * `localSettings`, i.e. `.claude/settings.local.json`). When Claude Code * offers no suggestion — common for MCP tools — fall back to a whole-tool * session allow rule so the decision still sticks for the session instead of * silently degrading into a one-shot accept. */function toSessionPermissionUpdates(  toolName: string,  suggestions: ReadonlyArray<PermissionUpdate> | undefined,): Array<PermissionUpdate> {  const sessionScoped = (suggestions ?? []).map(    (suggestion): PermissionUpdate => ({ ...suggestion, destination: "session" }),  );  if (sessionScoped.length > 0) {    return sessionScoped;  }  return [    {      type: "addRules",      rules: [{ toolName }],      behavior: "allow",      destination: "session",    },  ];} interface PendingUserInput {  readonly questions: ReadonlyArray<UserInputQuestion>;  readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;  /** Unparks the waiting handler as cancelled. Session teardown must run it. */  readonly cancel: Effect.Effect<void>;} interface ToolInFlight {  readonly itemId: string;  readonly itemType: CanonicalItemType;  readonly toolName: string;  readonly title: string;  readonly detail?: string;  readonly input: Record<string, unknown>;  readonly partialInputJson: string;  readonly lastEmittedInputFingerprint?: string;  /** Owning agent when this tool ran inside a subagent (see attribution note). */  readonly agentId?: string;  readonly parentToolUseId?: string;} interface ClaudeTaskState {  readonly id: string;  subject: string;  status: PlanStep["status"];  readonly blockedBy: Set<string>;} /** * Agent identity captured from task_started and repeated on every subsequent * task.* payload, so client folds can reconstruct an agent even when its * start row aged out of activity retention. */interface ClaudeTaskAgentState {  readonly taskId: string;  toolUseId: string | undefined;  description: string | undefined;  subagentType: string | undefined;  taskType: string | undefined;  workflowName: string | undefined;  skipTranscript: boolean;  runHandles: TaskRunHandles | undefined;  /** Set when this task was launched from inside a subagent. */  owningAgentId: string | undefined;  /** Seeded from the launching tool's input; refined by the subagent's own   * assistant snapshots (authoritative API model). */  model: string | undefined;  effort: string | undefined;} interface ClaudeSessionContext {  session: ProviderSession;  readonly promptQueue: Queue.Queue<PromptQueueItem>;  readonly query: ClaudeQueryRuntime;  streamFiber: Fiber.Fiber<void, Error> | undefined;  readonly startedAt: string;  readonly basePermissionMode: PermissionMode | undefined;  currentApiModelId: string | undefined;  /** Effective effort for the session's turns; subagents without an explicit   * effort override inherit this. */  currentEffort: string | undefined;  resumeSessionId: string | undefined;  readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;  readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;  readonly turns: Array<{    id: TurnId;    items: Array<unknown>;  }>;  readonly inFlightTools: Map<number, ToolInFlight>;  readonly claudeTasks: Map<string, ClaudeTaskState>;  readonly taskAgents: Map<string, ClaudeTaskAgentState>;  /**   * Last emitted workflow-member fingerprint per member slot. A coordinator   * task_progress repeats the FULL member array every tick; without a   * material-transition filter one provider tick fans out into up to 100   * runtime events (event-log writes, queue pressure, client reducer work)   * even when nothing changed for most members.   */  readonly workflowMemberFingerprints: Map<string, string>;  /** Task ids that have started and not yet reached a terminal state. */  readonly liveTaskIds: Set<string>;  turnState: ClaudeTurnState | undefined;  lastKnownContextWindow: number | undefined;  lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;  lastKnownTotalProcessedTokens: number | undefined;  lastAssistantUuid: string | undefined;  lastThreadStartedId: string | undefined;  stopped: boolean;} interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {  readonly interrupt: () => Promise<void>;  /** SDK Query.stopTask — present on real queries; optional for test doubles. */  readonly stopTask?: (taskId: string) => Promise<void>;  readonly setModel: (model?: string) => Promise<void>;  readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;  readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;  readonly getContextUsage?: () => Promise<SDKControlGetContextUsageResponse>;  readonly close: () => void;} export interface ClaudeAdapterLiveOptions {  readonly instanceId?: ProviderInstanceId;  readonly environment?: NodeJS.ProcessEnv;  readonly createQuery?: (input: {    readonly prompt: AsyncIterable<SDKUserMessage>;    readonly options: ClaudeQueryOptions;  }) => ClaudeQueryRuntime;  readonly nativeEventLogPath?: string;  readonly nativeEventLogger?: EventNdjsonLogger;} 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 hasDurableClaudeSessionId(message: SDKMessage): boolean {  if (message.type !== "system") {    return true;  }   return (    message.subtype !== "hook_started" &&    message.subtype !== "hook_progress" &&    message.subtype !== "hook_response"  );} function toMessage(cause: unknown, fallback: string): string {  if (cause instanceof Error && cause.message.length > 0) {    return cause.message;  }  return fallback;} function normalizeClaudeStreamMessages(  cause: Cause.Cause<ProviderAdapterProcessError>,): ReadonlyArray<string> {  const errors: Array<string> = [];  for (const error of Cause.prettyErrors(cause)) {    const message = error.message.trim();    if (message.length > 0) {      errors.push(message);    }  }  if (errors.length > 0) {    return errors;  }   const squashed = toMessage(Cause.squash(cause), "").trim();  return squashed.length > 0 ? [squashed] : [];} function getEffectiveClaudeAgentEffort(  effort: string | null | undefined,  model: string | null | undefined,): ClaudeSdkEffort | null {  const normalized = normalizeClaudeCliEffort(effort, model);  return normalized ? (normalized as ClaudeSdkEffort) : null;} function isClaudeInterruptedMessage(message: string): boolean {  const normalized = message.toLowerCase();  return (    normalized.includes("all fibers interrupted without error") ||    normalized.includes("request was aborted") ||    normalized.includes("interrupted by user")  );} function isClaudeInterruptedCause(cause: Cause.Cause<ProviderAdapterProcessError>): boolean {  return (    Cause.hasInterruptsOnly(cause) ||    normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) ||    cause.reasons.some(      (reason) =>        Cause.isFailReason(reason) && isClaudeInterruptedMessage(toMessage(reason.error.cause, "")),    )  );} function resultErrorsText(result: SDKResultMessage): string {  return "errors" in result && Array.isArray(result.errors)    ? result.errors.join(" ").toLowerCase()    : "";} /** * First user-facing error from a non-success result. "[ede_diagnostic] ..." * entries are CLI-internal telemetry (the CLI hides them from its own UI too), * so they must never become the error banner. */function resultUserFacingError(result: SDKResultMessage): string | undefined {  if (result.subtype === "success" || !Array.isArray(result.errors)) {    return undefined;  }  return result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));} function isInterruptedResult(result: SDKResultMessage): boolean {  // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields  // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and  // is_error: true), interrupting mid-stream yields "aborted_streaming".  if (    result.terminal_reason === "aborted_tools" ||    result.terminal_reason === "aborted_streaming"  ) {    return true;  }   const errors = resultErrorsText(result);  if (errors.includes("interrupt")) {    return true;  }   return (    result.subtype === "error_during_execution" &&    result.is_error === false &&    (errors.includes("request was aborted") ||      errors.includes("interrupted by user") ||      errors.includes("aborted"))  );} function asRuntimeItemId(value: string): RuntimeItemId {  return RuntimeItemId.make(value);} function maxClaudeContextWindowFromModelUsage(  modelUsage: Record<string, ModelUsage> | undefined,): number | undefined {  if (!modelUsage) return undefined;   let maxContextWindow: number | undefined;  for (const value of Object.values(modelUsage)) {    const contextWindow = value.contextWindow;    maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);  }   return maxContextWindow;} function selectedClaudeContextWindow(  modelSelection: ModelSelection | undefined,): number | undefined {  switch (modelSelection?.model) {    case "claude-opus-4-8":    case "claude-opus-4-7":      // Always 1M at the API; these models expose no contextWindow option.      return 1_000_000;  }   switch (resolveClaudeContextWindow(modelSelection)) {    case "1m":      return 1_000_000;    case "200k":      return 200_000;    default:      return undefined;  }} function finiteNonNegativeInteger(value: unknown): number | undefined {  return typeof value === "number" && Number.isFinite(value) && value >= 0    ? Math.round(value)    : undefined;} function finitePositiveInteger(value: unknown): number | undefined {  return typeof value === "number" && Number.isFinite(value) && value > 0    ? Math.round(value)    : undefined;} function claudeUsageInputTokens(usage: Record<string, unknown>): number {  return (    (finiteNonNegativeInteger(usage.input_tokens) ?? 0) +    (finiteNonNegativeInteger(usage.cache_creation_input_tokens) ?? 0) +    (finiteNonNegativeInteger(usage.cache_read_input_tokens) ?? 0)  );} function claudeUsageOutputTokens(usage: Record<string, unknown>): number {  return finiteNonNegativeInteger(usage.output_tokens) ?? 0;} function lastClaudeUsageIteration(  value: Record<string, unknown>,): Record<string, unknown> | undefined {  const iterations = Array.isArray(value.iterations) ? value.iterations : [];  return iterations.findLast(    (iteration): iteration is Record<string, unknown> =>      iteration !== null && typeof iteration === "object" && !Array.isArray(iteration),  );} function claudeTotalProcessedTokens(value: unknown): number | undefined {  if (!value || typeof value !== "object" || Array.isArray(value)) {    return undefined;  }   const usage = value as Record<string, unknown>;  const explicitTotal = finiteNonNegativeInteger(usage.total_tokens);

Lines 1–500 of 4644.