5160 lines · 176.5 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 SDKRateLimitInfo,  type SDKResultMessage,  type SettingSource,  type SDKUserMessage,  type ModelUsage,} from "@anthropic-ai/claude-agent-sdk";import { parseCliArgs } from "@t3tools/shared/cliArgs";import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";import {  ApprovalRequestId,  classifyTaskAgentKind,  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 TurnTokenUsage,  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 {  CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,  formatClaudeResumeCompactionQuestion,} from "@t3tools/shared/claudeCompaction";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 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 { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";import {  BUNDLED_CLAUDE_MODEL_CATALOG,  type ClaudeModelCatalog,  getClaudeCatalogModelCapabilities,  isClaudeCatalogUltracodeEffort,  normalizeClaudeCatalogEffort,  resolveClaudeCatalogApiModelId,  resolveClaudeCatalogContextWindowTokens,  resolveClaudeCatalogEffort,  resolveClaudeModelSlug,  scopeClaudeModelCatalog,} from "../ClaudeModelCatalog.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>;  latestAssistantUsage: unknown | undefined;  compactedSinceLatestAssistantUsage: boolean;  hasSubagents: boolean;  nextSyntheticAssistantBlockIndex: number;  authenticationFailureMessage: string | undefined;  rejectedRateLimitTypes: Set<string>;  latestAssistantRateLimited: boolean;} 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;} /** * How many racing snapshot models to buffer per session. A snapshot whose * task_started never arrives would otherwise pin its entry for the session's * lifetime; oldest entries evict first. */const PENDING_TASK_MODEL_CAP = 64; /** * Buffers a subagent snapshot's authoritative model under its * parent_tool_use_id, for snapshots that beat their task_started to the * stream. task_started consumes the entry when it registers the task. */function rememberPendingTaskModel(  pending: Map<string, string>,  parentToolUseId: string,  model: string,): void {  pending.set(parentToolUseId, model);  if (pending.size > PENDING_TASK_MODEL_CAP) {    const oldest = pending.keys().next();    if (!oldest.done) {      pending.delete(oldest.value);    }  }} 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>;  /**   * Authoritative subagent models from assistant snapshots that arrived before   * their task_started registered the task, keyed by parent_tool_use_id.   * Written through `rememberPendingTaskModel`, consumed by task_started.   */  readonly pendingTaskModels: Map<string, string>;  /**   * 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;  /** Limits already announced for the running turn, keyed `window:resetsAt`. */  announcedUsageLimits: { turnId: string; keys: Set<string> } | undefined;  stopped: boolean;} interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {  readonly setModel: (model?: string) => Promise<void>;  readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;  readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;  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;  readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;  /** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */  readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;} 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(  catalog: ClaudeModelCatalog,  effort: string | null | undefined,  model: string | null | undefined,): ClaudeSdkEffort | null {  const normalized = normalizeClaudeCatalogEffort(catalog, 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()    : "";} /** Failure text for structured terminal reasons, including success-tagged failures. */function terminalResultError(  reason: SDKResultMessage["terminal_reason"],  failureHint?: string,): string | undefined {  switch (reason) {    case "api_error":      return failureHint ?? "Claude gave up after repeated API errors.";    case "malformed_tool_use_exhausted":      return "Claude gave up after repeated malformed tool calls.";    case "budget_exhausted":      return "Claude stopped: the turn's token budget was exhausted.";    case "structured_output_retry_exhausted":      return "Claude could not produce the requested structured output.";    case "tool_deferred_unavailable":      return "Claude could not resume a deferred tool call: the tool is no longer available.";    case "turn_setup_failed":      return "Claude could not start the turn.";    case "blocking_limit":      return "Claude stopped: a usage limit blocked the request.";    case "rapid_refill_breaker":      return "Claude stopped: the context refilled too quickly after compaction.";    case "prompt_too_long":      return "Claude stopped: the prompt exceeds the model's context window.";    case "image_error":      return "Claude stopped: an image in the conversation could not be processed.";    case "model_error":      return "Claude stopped: the model returned an error.";    default:      return undefined;  }} 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"))  );} const CLAUDE_USAGE_LIMIT_WINDOWS = {  five_hour: "5-hour",  seven_day: "7-day",

Lines 1–500 of 5160.