Files
paseo/packages/server/src/server/agent/providers/claude/agent.ts
T
Mohamed Boudra 22fadbb217 Run option, model, thinking and mode changes in the agent's lifecycle lane
The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.
2026-11-08 12:41:49 +01:00

7193 lines
241 KiB
TypeScript

import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
import path from "node:path";
import { LRUCache } from "lru-cache";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type PermissionMode,
type PermissionResult,
type PermissionUpdate,
type Query,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKResultMessage,
type SDKSystemMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "pino";
import {
mapClaudeCanceledToolCall,
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
readTaskNotificationToolUseIdFromHistoryRecord,
readTaskNotificationFromHistoryRecord,
} from "./task-notification-tool-call.js";
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
import {
getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
resolveConfiguredClaudeModel,
} from "./models.js";
import {
CLAUDE_DISABLED_THINKING_OPTION_ID,
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { expandTilde } from "../../../../utils/path.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { readClaudeSubagentHandback } from "./subagent-handback.js";
import { ClaudeTaskState } from "./task-state.js";
import {
ClaudeTaskProtocolSource,
type ClaudeHookObservationInput,
} from "./subagents/live-source.js";
import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js";
import { mapTaskStatus } from "./subagents/task-status.js";
import type { ProviderSubagentStatus } from "../../provider-subagents/store.js";
import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js";
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
formatProviderDiagnostic,
formatProviderDiagnosticError,
} from "../diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
import {
applyClaudeToolPolicy,
ClaudeProviderOptionsSchema,
type ClaudeProviderOptions,
} from "./options.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
findSteeredMessageParent,
realClaudeRewindSdk,
revertClaudeConversation,
revertClaudeFiles,
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "../provider-image-output.js";
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPermissionUpdate,
type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type SteerActiveTurnOptions,
type SteerResult,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type McpServerConfig,
type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
type ResolvedProviderLaunch,
} from "../../provider-launch-config.js";
import { withTimeout } from "../../../../utils/promise-timeout.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { execCommand } from "../../../../utils/spawn.js";
import { composeSystemPromptParts } from "../../system-prompt.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
"user",
"project",
"local",
];
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function normalizeClaudeAskUserQuestionRequestInput(
toolName: string,
input: AgentMetadata,
): AgentMetadata {
if (toolName !== "AskUserQuestion" || !Array.isArray(input.questions)) {
return input;
}
// Claude Code's AskUserQuestion schema says "Other" is host-provided, not a
// model-supplied option. Paseo's shared question UI uses allowOther for that
// freeform answer path.
return {
...input,
questions: input.questions.map((item) => {
if (!isMetadata(item)) {
return item;
}
return {
...item,
allowOther: true,
};
}),
};
}
function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetadata {
if (!Array.isArray(input.questions)) {
return input;
}
return {
...input,
questions: input.questions.map((item) => {
if (!isMetadata(item) || !("allowOther" in item)) {
return item;
}
const itemForClaude: AgentMetadata = { ...item };
delete itemForClaude.allowOther;
return itemForClaude;
}),
};
}
/**
* The answers this response will actually deliver, keyed by full question text,
* or null when there is no question list and answers map to work from.
*
* Shared by the normalizer and by the check that rejects an undeliverable
* answer, so the two can never disagree about what "deliverable" means.
*/
function resolveClaudeAskUserQuestionAnswers(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): Record<string, string> | null {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
const questions =
(Array.isArray(base.questions) ? base.questions : null) ??
(Array.isArray(fallback.questions) ? fallback.questions : null);
const answers = isMetadata(base.answers) ? base.answers : null;
if (!questions || !answers) {
return null;
}
const normalizedAnswers: Record<string, string> = {};
for (const item of questions) {
const question = isMetadata(item) ? item : null;
if (!question) {
continue;
}
const questionText = readNonEmptyString(question.question);
if (!questionText) {
continue;
}
const header = readNonEmptyString(question.header);
const answer =
readNonEmptyString(answers[questionText]) ??
(header ? readNonEmptyString(answers[header]) : null);
if (answer) {
normalizedAnswers[questionText] = answer;
}
}
return normalizedAnswers;
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput);
if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) {
return merged;
}
return {
...merged,
answers: normalizedAnswers,
};
}
class ClaudeQuestionAnswerRequiredError extends Error {
readonly code = "question_answer_required" as const;
constructor(readonly requestId: string) {
super(
`Permission request '${requestId}' requires updatedInput.answers keyed by a ` +
`question's full text or header, with a non-empty string value. ` +
`The request is still pending; retry with that shape.`,
);
this.name = "ClaudeQuestionAnswerRequiredError";
}
}
function assertClaudeQuestionAnswerDeliverable(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): void {
if (request.kind !== "question" || response.behavior !== "allow") {
return;
}
const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input);
if (!answers || Object.keys(answers).length === 0) {
throw new ClaudeQuestionAnswerRequiredError(request.id);
}
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isObjectRecord(value) ? value : undefined;
}
function isUnknownArray(value: unknown): value is readonly unknown[] {
return Array.isArray(value);
}
function isImageMimeType(
value: string,
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
return (
value === "image/jpeg" ||
value === "image/png" ||
value === "image/gif" ||
value === "image/webp"
);
}
type TurnState = "idle" | "foreground" | "autonomous";
interface EventIdentifiers {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
}
interface AutonomousTurnState {
id: string;
}
interface AsyncMessageInput<T> {
push: (item: T) => void;
end: () => void;
iterable: AsyncIterable<T>;
}
interface ClaudeReplayOwnership {
restoredIds: ReadonlySet<string>;
toolOwners: ReadonlyMap<string, string>;
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
}
interface ClaudeSubagentCardFacts {
title?: string;
description?: string;
}
interface PersistedTimelineEntry {
item: AgentTimelineItem;
timestamp?: string;
}
interface ClaudeRewindTurnAnchor {
userMessageId: string;
assistantMessageId: string | null;
}
type ClaudeConversationRewindTarget =
| { kind: "fresh-session" }
| { kind: "fork"; messageId: string };
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
};
const DEFAULT_MODES: AgentMode[] = [
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
},
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
},
{
id: "auto",
label: "Auto mode",
description: "Uses a model classifier to review permission prompts automatically",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
},
];
const VALID_CLAUDE_MODES = new Set(DEFAULT_MODES.map((mode) => mode.id));
const REWIND_COMMAND_NAME = "rewind";
const REWIND_COMMAND: AgentSlashCommand = {
name: REWIND_COMMAND_NAME,
description: "Rewind tracked files to a previous user message",
argumentHint: "[user_message_uuid]",
};
const CLAUDE_ROOT_ONLY_COMMANDS = new Set([
"clear",
"compact",
"context",
"debug",
"extra-usage",
"heapdump",
"init",
"loop",
"schedule",
"usage",
]);
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
const STEER_SUPERSEDED_PERMISSION_MESSAGE =
"The user answered with a message instead of approving. Their message follows.";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
interface SlashCommandInvocation {
commandName: string;
args?: string;
rawInput: string;
}
// Claude Code reserves `__`-prefixed commands for its own server-launched sessions
// (for example `/__remote-workflow`); a user cannot run them from a Paseo agent.
function isClaudeInternalSlashCommand(commandName: string): boolean {
return commandName.startsWith("__");
}
function classifyClaudeSlashCommand(commandName: string): AgentSlashCommand["kind"] {
// Claude exposes commands and skills as one flat SDK list, without structured source
// metadata. Keep obvious root-only/session controls out of inline autocomplete and
// treat the rest as skills; the worst failure mode is an inert inline suggestion.
return CLAUDE_ROOT_ONLY_COMMANDS.has(commandName) ? "command" : "skill";
}
type ClaudeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
provider: "claude";
providerOptions: ClaudeProviderOptions;
};
export interface ClaudeContentChunk {
type: string;
[key: string]: unknown;
}
interface ClaudeAgentClientOptions {
discoverModels?: typeof discoverClaudeModels;
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
rewindSdk?: ClaudeRewindSdk;
}
type ClaudeImportDescriptorResult =
| { type: "parsed"; descriptor: ImportableProviderSession | null }
| { type: "unreadable"; error: Error };
interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
queryFactory?: ClaudeQueryFactory;
resolveBinary: () => Promise<string>;
rewindSdk?: ClaudeRewindSdk;
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption =
| ClaudeThinkingEffort
| typeof CLAUDE_DISABLED_THINKING_OPTION_ID
| typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path";
if (process.env["PATH"] !== undefined) return "PATH";
return null;
}
function errorToMessageString(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return "";
}
/**
* Claude opens every main-session turn with system/init and closes it with exactly one result:
* a prompt, a wake-up after background work, a slash command, an API error. Background subagents
* never emit either on the main stream.
*/
function isClaudeTurnInit(message: SDKMessage): boolean {
return message.type === "system" && message.subtype === "init";
}
function firstStringField(
input: Record<string, unknown>,
primaryKey: string,
secondaryKey: string,
): string | undefined {
const primary = input[primaryKey];
if (typeof primary === "string") return primary;
const secondary = input[secondaryKey];
if (typeof secondary === "string") return secondary;
return undefined;
}
function extractSessionIdRaw(msg: {
session_id?: unknown;
sessionId?: unknown;
session?: { id?: unknown } | null;
}): string {
if (typeof msg.session_id === "string") return msg.session_id;
if (typeof msg.sessionId === "string") return msg.sessionId;
if (typeof msg.session?.id === "string") return msg.session.id;
return "";
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh" ||
value === "max"
);
}
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
return (
value === CLAUDE_DISABLED_THINKING_OPTION_ID ||
value === CLAUDE_ULTRACODE_THINKING_OPTION_ID ||
isClaudeThinkingEffort(value)
);
}
function assertClaudeThinkingOptionSupported(
modelId: string | null | undefined,
thinkingOptionId: string | null | undefined,
): void {
if (
thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID ||
resolveClaudeDisabledThinkingForModel(modelId).supported
) {
return;
}
throw new Error(
`Thinking option '${thinkingOptionId}' is not available for model '${modelId ?? "default"}'`,
);
}
interface ClaudeOptionsLogSummary {
cwd: string | null;
permissionMode: string | null;
model: string | null;
includePartialMessages: boolean;
settingSources: string[];
enableFileCheckpointing: boolean;
hasResume: boolean;
maxThinkingTokens: number | null;
hasEnv: boolean;
envKeyCount: number;
hasMcpServers: boolean;
mcpServerNames: string[];
systemPromptMode: "none" | "string" | "preset" | "custom";
systemPromptPreset: string | null;
hasCanUseTool: boolean;
hasSpawnOverride: boolean;
hasStderrHandler: boolean;
pathToClaudeCodeExecutable: string | null;
persistSession: boolean | null;
fastMode: boolean | null;
}
const MAX_RECENT_STDERR_CHARS = 4000;
const STDERR_FLUSH_WAIT_MS = 150;
const STDERR_FLUSH_POLL_INTERVAL_MS = 10;
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
const systemPromptRaw = options.systemPrompt;
const systemPromptSummary = (() => {
if (!systemPromptRaw) {
return { mode: "none" as const, preset: null };
}
if (typeof systemPromptRaw === "string") {
return { mode: "string" as const, preset: null };
}
const prompt = toObjectRecord(systemPromptRaw);
const promptType = typeof prompt?.type === "string" ? prompt.type : "custom";
return {
mode: promptType === "preset" ? ("preset" as const) : ("custom" as const),
preset: typeof prompt?.preset === "string" && prompt.preset.length > 0 ? prompt.preset : null,
};
})();
const mcpServerNames = options.mcpServers ? Object.keys(options.mcpServers).sort() : [];
return {
cwd: typeof options.cwd === "string" ? options.cwd : null,
permissionMode: typeof options.permissionMode === "string" ? options.permissionMode : null,
model: typeof options.model === "string" ? options.model : null,
includePartialMessages: options.includePartialMessages === true,
settingSources: Array.isArray(options.settingSources) ? options.settingSources : [],
enableFileCheckpointing: options.enableFileCheckpointing === true,
hasResume: typeof options.resume === "string" && options.resume.length > 0,
maxThinkingTokens:
typeof options.maxThinkingTokens === "number" ? options.maxThinkingTokens : null,
hasEnv: !!options.env,
envKeyCount: Object.keys(options.env ?? {}).length,
hasMcpServers: mcpServerNames.length > 0,
mcpServerNames,
systemPromptMode: systemPromptSummary.mode,
systemPromptPreset: systemPromptSummary.preset,
hasCanUseTool: typeof options.canUseTool === "function",
hasSpawnOverride: typeof options.spawnClaudeCodeProcess === "function",
hasStderrHandler: typeof options.stderr === "function",
pathToClaudeCodeExecutable:
typeof options.pathToClaudeCodeExecutable === "string"
? options.pathToClaudeCodeExecutable
: null,
persistSession: typeof options.persistSession === "boolean" ? options.persistSession : null,
fastMode: readClaudeFastModeSetting(options.settings),
};
}
function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean | null {
if (!settings || typeof settings === "string") {
return null;
}
return typeof settings.fastMode === "boolean" ? settings.fastMode : null;
}
type ClaudeSettingsObject = NonNullable<Exclude<ClaudeOptions["settings"], string>>;
// Mirrors `claude --settings`: the value is inline JSON or a path to a JSON file,
// resolved against the session cwd like the Claude process would.
async function readClaudeCliSettings(value: string, cwd: string): Promise<ClaudeSettingsObject> {
const trimmed = value.trim();
const isInlineJson = trimmed.startsWith("{");
const source = isInlineJson ? "--settings JSON" : path.resolve(cwd, expandTilde(trimmed));
let raw = trimmed;
if (!isInlineJson) {
try {
raw = await fsPromises.readFile(source, "utf8");
} catch (error) {
throw new Error(
`Failed to read Claude settings file ${source}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`Claude ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Claude ${source} must contain a JSON object`);
}
return parsed as ClaudeSettingsObject;
}
function mergeClaudeSettings(
settings: ClaudeOptions["settings"],
updates: NonNullable<Exclude<ClaudeOptions["settings"], string>>,
): ClaudeOptions["settings"] {
if (!settings || typeof settings === "string") {
return settings ?? updates;
}
return { ...settings, ...updates };
}
function isToolResultTextBlock(value: unknown): value is { type: "text"; text: string } {
return (
!!value &&
typeof value === "object" &&
(value as { type?: unknown }).type === "text" &&
typeof (value as { text?: unknown }).text === "string"
);
}
function normalizeForDeterministicString(value: unknown, seen: WeakSet<object>): unknown {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "function") {
return "[function]";
}
if (typeof value === "symbol") {
return value.toString();
}
if (typeof value === "undefined") {
return "[undefined]";
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeForDeterministicString(entry, seen));
}
if (typeof value === "object") {
const objectValue = value;
if (seen.has(objectValue)) {
return "[circular]";
}
seen.add(objectValue);
const record = toObjectRecord(value);
if (!record) {
seen.delete(objectValue);
return "[invalid]";
}
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
normalized[key] = normalizeForDeterministicString(record[key], seen);
}
seen.delete(objectValue);
return normalized;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[unsupported]";
}
function deterministicStringify(value: unknown): string {
if (typeof value === "undefined") {
return "";
}
try {
const normalized = normalizeForDeterministicString(value, new WeakSet<object>());
if (typeof normalized === "string") {
return normalized;
}
return JSON.stringify(normalized);
} catch {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[unserializable]";
}
}
function coerceToolResultContentToString(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content) && content.every((block) => isToolResultTextBlock(block))) {
return content.map((block) => block.text).join("");
}
return deterministicStringify(content);
}
function toBase64ImageOutput(block: unknown): ProviderImageOutput | null {
const record = toObjectRecord(block);
if (!record || record.type !== "image") {
return null;
}
const source = toObjectRecord(record.source);
if (!source || source.type !== "base64" || typeof source.data !== "string") {
return null;
}
return {
data: source.data,
mimeType: typeof source.media_type === "string" ? source.media_type : null,
};
}
// Claude returns images inside tool_result content as base64 Anthropic blocks. Left in place they
// reach coerceToolResultContentToString, which JSON.stringifies the whole array — dumping base64
// into the tool output. We pull those blocks out to render them as image markdown and leave a
// "[image]" placeholder so image-only results still produce non-empty output.
function splitClaudeToolResultImages(content: unknown): {
images: ProviderImageOutput[];
text: unknown;
} {
if (!Array.isArray(content)) {
return { images: [], text: content };
}
const images: ProviderImageOutput[] = [];
const text = content.map((block) => {
const image = toBase64ImageOutput(block);
if (image) {
images.push(image);
return { type: "text", text: "[image]" };
}
return block;
});
return { images, text };
}
function normalizeClaudeTranscriptText(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
const LOCAL_COMMAND_STDOUT_PATTERN =
/^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
const CLAUDE_COMMAND_MESSAGE_PATTERN = /<command-message>([\s\S]*?)<\/command-message>/;
const CLAUDE_COMMAND_ARGS_PATTERN = /<command-args>([\s\S]*?)<\/command-args>/;
const CLAUDE_COMMAND_NAME_PATTERN = /<command-name>([\s\S]*?)<\/command-name>/;
function isClaudeLocalCommandStdout(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && LOCAL_COMMAND_STDOUT_PATTERN.test(normalized);
}
function isClaudeTranscriptNoiseText(value: unknown): boolean {
return (
isClaudeInterruptPlaceholderText(value) ||
isClaudeNoResponsePlaceholderText(value) ||
isClaudeLocalCommandStdout(value)
);
}
function collectClaudeTextContentParts(content: unknown): string[] {
if (typeof content === "string") {
const normalized = normalizeClaudeTranscriptText(content);
return normalized ? [normalized] : [];
}
if (!isUnknownArray(content)) {
return [];
}
const parts: string[] = [];
for (const block of content) {
const blockRecord = toObjectRecord(block);
if (!blockRecord) {
continue;
}
const text = normalizeClaudeTranscriptText(blockRecord.text);
if (text) {
parts.push(text);
continue;
}
const input = normalizeClaudeTranscriptText(blockRecord.input);
if (input) {
parts.push(input);
}
}
return parts;
}
function isClaudeTranscriptNoiseContent(content: unknown): boolean {
const parts = collectClaudeTextContentParts(content);
return parts.length > 0 && parts.every((part) => isClaudeTranscriptNoiseText(part));
}
export function extractUserMessageText(content: unknown): string | null {
if (typeof content === "string") {
const normalized = content.trim();
if (!normalized || isClaudeTranscriptNoiseText(normalized)) {
return null;
}
return normalizeClaudeUserPromptText(normalized);
}
if (!Array.isArray(content)) {
return null;
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = typeof block.text === "string" ? block.text : undefined;
if (text && text.trim()) {
const trimmed = text.trim();
if (!isClaudeTranscriptNoiseText(trimmed)) {
const normalized = normalizeClaudeUserPromptText(trimmed);
if (normalized) {
parts.push(normalized);
}
}
continue;
}
const input = typeof block.input === "string" ? block.input : undefined;
if (input && input.trim()) {
const trimmed = input.trim();
if (!isClaudeTranscriptNoiseText(trimmed)) {
const normalized = normalizeClaudeUserPromptText(trimmed);
if (normalized) {
parts.push(normalized);
}
}
}
}
if (parts.length === 0) {
return null;
}
const combined = parts.join("\n\n").trim();
return combined.length > 0 ? combined : null;
}
interface PendingPermission {
request: AgentPermissionRequest;
resolve: (result: PermissionResult) => void;
reject: (error: Error) => void;
cleanup?: () => void;
}
type ToolUseClassification = "generic" | "command" | "file_change";
interface ToolUseCacheEntry {
id: string;
name: string;
server: string;
classification: ToolUseClassification;
started: boolean;
commandText?: string;
files?: { path: string; kind: string }[];
input?: AgentMetadata | null;
}
function isMetadata(value: unknown): value is AgentMetadata {
return typeof value === "object" && value !== null;
}
function createDefaultToolUseCacheEntry(id: string, block: ClaudeContentChunk): ToolUseCacheEntry {
const nameFromBlock =
typeof block.name === "string" && block.name.length > 0 ? block.name : "tool";
let server: string;
if (typeof block.server === "string" && block.server.length > 0) {
server = block.server;
} else if (typeof block.name === "string" && block.name.length > 0) {
server = block.name;
} else {
server = "tool";
}
return {
id,
name: nameFromBlock,
server,
classification: "generic",
started: false,
};
}
function readTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function isMcpServerConfig(value: unknown): value is McpServerConfig {
if (!isMetadata(value)) {
return false;
}
const type = value.type;
if (type === "stdio") {
return typeof value.command === "string";
}
if (type === "http" || type === "sse") {
return typeof value.url === "string";
}
return false;
}
function isMcpServersRecord(value: unknown): value is Record<string, McpServerConfig> {
if (!isMetadata(value)) {
return false;
}
for (const config of Object.values(value)) {
if (!isMcpServerConfig(config)) {
return false;
}
}
return true;
}
function isPermissionMode(value: string | undefined): value is PermissionMode {
return typeof value === "string" && VALID_CLAUDE_MODES.has(value);
}
function isTruthyEnvValue(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return (
normalized !== undefined &&
normalized.length > 0 &&
normalized !== "0" &&
normalized !== "false" &&
normalized !== "no" &&
normalized !== "off"
);
}
function claudeAutoModeUnavailableOn(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null {
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) {
return "Bedrock";
}
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)) {
return "Vertex";
}
return null;
}
function assertClaudeModeCanRun(mode: PermissionMode, env: NodeJS.ProcessEnv): void {
if (mode !== "auto") {
return;
}
const transport = claudeAutoModeUnavailableOn(env);
if (transport === null) {
return;
}
throw new Error(
`Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses ${transport}. Select another permission mode or unset the ${transport === "Bedrock" ? "CLAUDE_CODE_USE_BEDROCK" : "CLAUDE_CODE_USE_VERTEX"} environment variable.`,
);
}
function claudeModeCatalog(env: NodeJS.ProcessEnv): {
modes: AgentMode[];
defaultModeId: PermissionMode;
} {
if (claudeAutoModeUnavailableOn(env)) {
return { modes: DEFAULT_MODES.filter((mode) => mode.id !== "auto"), defaultModeId: "default" };
}
return { modes: DEFAULT_MODES, defaultModeId: "auto" };
}
function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
if (!isMetadata(metadata)) {
return {};
}
const result: Partial<AgentSessionConfig> = {};
if (metadata.provider === "claude" || metadata.provider === "codex") {
result.provider = metadata.provider;
}
if (typeof metadata.cwd === "string") {
result.cwd = metadata.cwd;
}
if (typeof metadata.modeId === "string") {
result.modeId = metadata.modeId;
}
if (typeof metadata.model === "string") {
result.model = metadata.model;
}
if (typeof metadata.title === "string" || metadata.title === null) {
result.title = metadata.title;
}
const providerOptions = ClaudeProviderOptionsSchema.safeParse(metadata.providerOptions);
if (providerOptions.success) {
result.providerOptions = providerOptions.data;
}
if (typeof metadata.systemPrompt === "string") {
result.systemPrompt = metadata.systemPrompt;
}
if (isMcpServersRecord(metadata.mcpServers)) {
result.mcpServers = metadata.mcpServers;
}
return result;
}
export function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig {
const eagerTools = config.alwaysLoad === true ? { alwaysLoad: true } : {};
switch (config.type) {
case "stdio":
return {
type: "stdio",
command: config.command,
args: config.args,
env: config.env,
...eagerTools,
};
case "http":
return {
type: "http",
url: config.url,
headers: config.headers,
...eagerTools,
};
case "sse":
return {
type: "sse",
url: config.url,
headers: config.headers,
...eagerTools,
};
}
throw new Error("Unhandled MCP server config type");
}
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
return isMetadata(value) && typeof value.type === "string";
}
function isPermissionUpdate(value: AgentPermissionUpdate): value is PermissionUpdate {
if (!isMetadata(value)) {
return false;
}
const type = value.type;
if (type !== "addRules" && type !== "replaceRules" && type !== "removeRules") {
return false;
}
const rules = value.rules;
const behavior = value.behavior;
const destination = value.destination;
return Array.isArray(rules) && typeof behavior === "string" && typeof destination === "string";
}
function resolvePermissionKind(
toolName: string,
input: Record<string, unknown>,
): AgentPermissionRequestKind {
if (toolName === "ExitPlanMode") return "plan";
if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) {
return "question";
}
return "tool";
}
// Notification previews fall back to serializing the raw request input when a
// permission request carries no title. For AskUserQuestion that fallback is the
// whole question object, so the notification reads as JSON. Summarize the first
// question the same way the OMP and Pi providers do for their ask_user
// permissions.
function buildClaudeQuestionPermissionSummary(
toolName: string,
input: AgentMetadata,
): { title?: string; description?: string } {
if (toolName !== "AskUserQuestion" || !Array.isArray(input.questions)) {
return {};
}
const question = input.questions.find(isMetadata);
const title = typeof question?.question === "string" ? question.question.trim() : "";
if (!title) {
return {};
}
const labels = Array.isArray(question?.options)
? question.options
.map((option) => {
if (typeof option === "string") return option.trim();
return isMetadata(option) && typeof option.label === "string" ? option.label.trim() : "";
})
.filter((label) => label.length > 0)
: [];
return labels.length > 0 ? { title, description: labels.join(" / ") } : { title };
}
function getClaudeModeLabel(modeId: PermissionMode): string {
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
}
function buildClaudePlanPermissionActions(
resumeMode: PermissionMode | null,
): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (resumeMode === "bypassPermissions" || resumeMode === "auto") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
interface TimelineFragment {
kind: "assistant" | "reasoning";
text: string;
}
interface TimelineMessageState {
id: string;
assistantText: string;
reasoningText: string;
emittedAssistantLength: number;
emittedReasoningLength: number;
stopped: boolean;
}
class TimelineAssembler {
private readonly messages = new Map<string, TimelineMessageState>();
private readonly finalizedMessageIds = new Set<string>();
private readonly activeMessageByRun = new Map<string, string>();
private syntheticMessageCounter = 0;
consume(input: {
message: SDKMessage;
runId: string | null;
messageIdHint?: string | null;
}): AgentTimelineItem[] {
if (input.message.type === "assistant") {
return this.consumeAssistantMessage(input.message, input.runId, input.messageIdHint ?? null);
}
if (input.message.type === "stream_event") {
return this.consumeStreamEvent(input.message, input.runId, input.messageIdHint ?? null);
}
return [];
}
private consumeAssistantMessage(
message: SDKMessage & { type: "assistant" },
runId: string | null,
messageIdHint: string | null,
): AgentTimelineItem[] {
const messageId =
this.readMessageIdFromAssistantMessage(message) ??
messageIdHint ??
this.resolveMessageId({ runId, createIfMissing: true, messageId: null });
if (!messageId) {
return [];
}
if (this.finalizedMessageIds.has(messageId)) {
return [];
}
const state = this.ensureMessageState(messageId, runId);
const fragments = this.extractFragments(message.message?.content);
return this.applyAbsoluteFragments(state, fragments);
}
private consumeStreamEvent(
message: SDKMessage & { type: "stream_event" },
runId: string | null,
messageIdHint: string | null,
): AgentTimelineItem[] {
const event = toObjectRecord(message.event) ?? {};
const eventType = readTrimmedString(event.type);
const streamEventMessageId = this.readMessageIdFromStreamEvent(event) ?? messageIdHint;
if (eventType === "message_start") {
const messageId = this.resolveMessageId({
runId,
createIfMissing: true,
messageId: streamEventMessageId,
});
if (!messageId) {
return [];
}
this.ensureMessageState(messageId, runId);
return [];
}
if (eventType === "message_stop") {
const messageId = this.resolveMessageId({
runId,
createIfMissing: false,
messageId: streamEventMessageId,
});
if (!messageId) {
return [];
}
return this.finalizeMessage(messageId, runId);
}
if (eventType === "content_block_start") {
return this.consumeDeltaContent(event.content_block, runId, streamEventMessageId);
}
if (eventType === "content_block_delta") {
return this.consumeDeltaContent(event.delta, runId, streamEventMessageId);
}
return [];
}
private consumeDeltaContent(
content: unknown,
runId: string | null,
messageIdHint: string | null,
): AgentTimelineItem[] {
const fragments = this.extractFragments(content);
if (fragments.length === 0) {
return [];
}
const messageId = this.resolveMessageId({
runId,
createIfMissing: true,
messageId: messageIdHint,
});
if (!messageId) {
return [];
}
const state = this.ensureMessageState(messageId, runId);
return this.appendFragments(state, fragments);
}
private appendFragments(
state: TimelineMessageState,
fragments: TimelineFragment[],
): AgentTimelineItem[] {
for (const fragment of fragments) {
if (fragment.kind === "assistant") {
state.assistantText += fragment.text;
} else {
state.reasoningText += fragment.text;
}
}
return this.emitNewContent(state);
}
private applyAbsoluteFragments(
state: TimelineMessageState,
fragments: TimelineFragment[],
): AgentTimelineItem[] {
const assistantText = fragments
.filter((fragment) => fragment.kind === "assistant")
.map((fragment) => fragment.text)
.join("");
const reasoningText = fragments
.filter((fragment) => fragment.kind === "reasoning")
.map((fragment) => fragment.text)
.join("");
if (assistantText.length > 0) {
if (!assistantText.startsWith(state.assistantText)) {
state.emittedAssistantLength = 0;
}
state.assistantText = assistantText;
}
if (reasoningText.length > 0) {
if (!reasoningText.startsWith(state.reasoningText)) {
state.emittedReasoningLength = 0;
}
state.reasoningText = reasoningText;
}
return this.emitNewContent(state);
}
private finalizeMessage(messageId: string, runId: string | null): AgentTimelineItem[] {
const state = this.messages.get(messageId);
if (!state) {
return [];
}
state.stopped = true;
const items = this.emitNewContent(state);
if (runId && this.activeMessageByRun.get(runId) === messageId) {
this.activeMessageByRun.delete(runId);
}
this.finalizedMessageIds.add(messageId);
this.messages.delete(messageId);
return items;
}
private emitNewContent(state: TimelineMessageState): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
const nextAssistantText = state.assistantText.slice(state.emittedAssistantLength);
if (
nextAssistantText.length > 0 &&
nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER &&
!isClaudeTranscriptNoiseText(nextAssistantText)
) {
state.emittedAssistantLength = state.assistantText.length;
items.push({ type: "assistant_message", text: nextAssistantText, messageId: state.id });
}
const nextReasoningText = state.reasoningText.slice(state.emittedReasoningLength);
if (nextReasoningText.length > 0) {
state.emittedReasoningLength = state.reasoningText.length;
items.push({ type: "reasoning", text: nextReasoningText });
}
return items;
}
private ensureMessageState(messageId: string, runId: string | null): TimelineMessageState {
const existing = this.messages.get(messageId);
if (existing) {
existing.stopped = false;
if (runId) {
this.activeMessageByRun.set(runId, messageId);
}
return existing;
}
const created: TimelineMessageState = {
id: messageId,
assistantText: "",
reasoningText: "",
emittedAssistantLength: 0,
emittedReasoningLength: 0,
stopped: false,
};
this.messages.set(messageId, created);
if (runId) {
this.activeMessageByRun.set(runId, messageId);
}
return created;
}
private resolveMessageId(input: {
runId: string | null;
createIfMissing: boolean;
messageId: string | null;
}): string | null {
if (input.messageId) {
return input.messageId;
}
if (input.runId) {
const active = this.activeMessageByRun.get(input.runId);
if (active) {
return active;
}
}
if (!input.createIfMissing) {
return null;
}
const synthetic = `synthetic-message-${++this.syntheticMessageCounter}`;
if (input.runId) {
this.activeMessageByRun.set(input.runId, synthetic);
}
return synthetic;
}
private extractFragments(content: unknown): TimelineFragment[] {
if (typeof content === "string") {
if (content.length === 0) {
return [];
}
return [{ kind: "assistant", text: content }];
}
const blocks = Array.isArray(content) ? content : [content];
const fragments: TimelineFragment[] = [];
for (const rawBlock of blocks) {
if (!isClaudeContentChunk(rawBlock)) {
continue;
}
if (
(rawBlock.type === "text" || rawBlock.type === "text_delta") &&
typeof rawBlock.text === "string" &&
rawBlock.text.length > 0
) {
fragments.push({ kind: "assistant", text: rawBlock.text });
}
if (
(rawBlock.type === "thinking" || rawBlock.type === "thinking_delta") &&
typeof rawBlock.thinking === "string" &&
rawBlock.thinking.length > 0
) {
fragments.push({ kind: "reasoning", text: rawBlock.thinking });
}
}
return fragments;
}
private readMessageIdFromAssistantMessage(
message: SDKMessage & { type: "assistant" },
): string | null {
const candidate = toObjectRecord(message);
const messageContainer = toObjectRecord(candidate?.message);
return (
readTrimmedString(candidate?.message_id) ?? readTrimmedString(messageContainer?.id) ?? null
);
}
private readMessageIdFromStreamEvent(event: Record<string, unknown>): string | null {
const messageContainer = toObjectRecord(event.message);
return readTrimmedString(event.message_id) ?? readTrimmedString(messageContainer?.id) ?? null;
}
}
function isSyntheticUserEntry(entry: unknown): boolean {
const candidate = toObjectRecord(entry);
if (!candidate) {
return false;
}
return (
candidate.isSynthetic === true || candidate.isMeta === true || Boolean(candidate.toolUseResult)
);
}
function isToolResultUserEntry(entry: unknown): boolean {
const candidate = toObjectRecord(entry);
if (!candidate) {
return false;
}
const message = toObjectRecord(candidate.message);
const content = message?.content;
return (
Array.isArray(content) && content.some((block) => toObjectRecord(block)?.type === "tool_result")
);
}
function isSyntheticHistoryUserEntry(entry: Record<string, unknown>): boolean {
return isSyntheticUserEntry(entry) && !isToolResultUserEntry(entry);
}
function firstTrimmedString(sources: readonly unknown[]): string | null {
for (const source of sources) {
const value = readTrimmedString(source);
if (value) {
return value;
}
}
return null;
}
function readTranscriptUuid(message: SDKMessage): string | null {
const root = toObjectRecord(message) ?? {};
const messageType = readTrimmedString(root.type);
if (messageType !== "user" && messageType !== "assistant") {
return null;
}
return firstTrimmedString([root.uuid]);
}
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
const root = toObjectRecord(message) ?? {};
const messageType = readTrimmedString(root.type);
const streamEvent = toObjectRecord(root.event);
const streamEventMessage = toObjectRecord(streamEvent?.message);
const messageContainer = toObjectRecord(root.message);
const messageIdFromUuid =
messageType === "user" || messageType === "assistant" || messageType === "system"
? root.uuid
: undefined;
return {
taskId: firstTrimmedString([
root.task_id,
streamEvent?.task_id,
streamEventMessage?.task_id,
messageContainer?.task_id,
]),
parentMessageId: firstTrimmedString([
root.parent_message_id,
streamEvent?.parent_message_id,
streamEventMessage?.parent_message_id,
messageContainer?.parent_message_id,
]),
messageId: firstTrimmedString([
root.message_id,
streamEvent?.message_id,
streamEventMessage?.id,
streamEventMessage?.message_id,
messageContainer?.id,
messageContainer?.message_id,
messageIdFromUuid,
]),
};
}
export class ClaudeAgentClient implements AgentClient {
private readonly discoverModels: typeof discoverClaudeModels;
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly rewindSdk: ClaudeRewindSdk;
private readonly scheduleImportDescriptors = createImportDescriptorScheduler(
(candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal),
);
private readonly importDescriptorCache = new LRUCache<string, ImportableProviderSession>({
max: 500,
maxSize: 8 * 1024 * 1024,
sizeCalculation: (descriptor, key) =>
128 + 2 * (JSON.stringify(descriptor).length + key.length),
});
constructor(options: ClaudeAgentClientOptions) {
this.discoverModels = options.discoverModels ?? discoverClaudeModels;
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
this.resolveVersion =
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
return resolveConfiguredClaudeModel(model);
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
persistSession: options?.persistSession,
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const metadata = coerceSessionMetadata(handle.metadata);
const merged: Partial<AgentSessionConfig> = { ...metadata, ...overrides };
if (!merged.cwd) {
throw new Error("Claude resume requires the original working directory in metadata");
}
const mergedConfig: AgentSessionConfig = {
...merged,
provider: "claude",
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
const session = new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
// This client discovers through host configuration, independent of project cwd.
return "host";
}
async fetchCatalog(
_options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
let claudeCodeVersion: string | undefined;
try {
claudeCodeVersion = await runProviderRefreshActivity(context, "version", () =>
this.resolveVersion(context?.signal),
);
} catch (error) {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const env = this.buildProviderEnv();
let discoveredModelIds = new Set<string>();
try {
const advertisedModels = await runProviderRefreshActivity(context, "models", () =>
this.discoverModels({
resolveBinary: this.resolveBinary,
runtimeSettings: this.runtimeSettings,
env,
signal: context?.signal,
}),
);
discoveredModelIds = new Set(
advertisedModels
.map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value))
.filter((id): id is string => id !== null),
);
} catch (error) {
context?.signal.throwIfAborted();
this.logger.warn(
{ err: error },
"Claude model discovery failed; omitting discovery-required models",
);
}
const models = await runProviderRefreshActivity(context, "settings", () =>
getClaudeModelsWithSettings(
this.logger,
claudeConfigDir(env),
claudeCodeVersion,
discoveredModelIds,
),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
...modeCatalog,
};
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
return createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
});
}
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return buildClaudeControls(this.assertConfig(config).model);
}
async listImportableSessions(
options?: ListImportableSessionsOptions & { signal?: AbortSignal },
): Promise<ImportableProviderSession[]> {
// Leave time to deliver the provider error before the client RPC expires at 60 seconds.
const deadline = AbortSignal.timeout(45_000);
const signal = options?.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
signal.throwIfAborted();
const configDir = claudeConfigDir(this.buildProviderEnv());
const sessionsRoot = options?.cwd
? claudeProjectDirSync(options.cwd, { configDir })
: path.join(configDir, "projects");
const rootExists = await pathExists(sessionsRoot);
signal.throwIfAborted();
if (!rootExists) return [];
const limit = options?.limit ?? 20;
const scanLimit = Math.min(options?.scanLimit ?? limit * 3, 500);
const candidates = await collectRecentClaudeSessions(sessionsRoot, scanLimit, {
rootIsProjectDir: Boolean(options?.cwd),
logger: this.logger,
});
const results = await this.scheduleImportDescriptors(candidates, signal);
const failures = results.filter((result) => result.type === "unreadable");
if (failures.length > 0 && failures.length === results.length) {
const errors = failures.map((result) => result.error);
throw new AggregateError(
errors,
`Cannot list Claude sessions: all ${failures.length} candidate transcripts have unreadable import metadata. ${errors[0]!.message}`,
);
}
const parsed = results.filter((result) => result.type === "parsed");
return parsed
.map((result) => result.descriptor)
.filter((session): session is ImportableProviderSession => session !== null)
.slice(0, limit)
.map((session) => structuredClone(session));
}
private async readImportDescriptor(
candidate: ClaudeSessionCandidate,
signal: AbortSignal,
): Promise<ClaudeImportDescriptorResult> {
signal.throwIfAborted();
const cached = this.importDescriptorCache.get(candidate.cacheKey);
if (cached) return { type: "parsed", descriptor: structuredClone(cached) };
let descriptor: ImportableProviderSession | null;
try {
descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime, signal);
} catch (error) {
signal.throwIfAborted();
if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error;
this.logger.warn(
{ err: error, path: candidate.path },
"Skipping Claude transcript with unreadable import metadata",
);
return { type: "unreadable", error };
}
if (descriptor) {
const current = await fsPromises.stat(candidate.path).catch((error: unknown) => {
if (!isExpectedClaudeImportFileError(error)) throw error;
return null;
});
signal.throwIfAborted();
if (current && claudeSessionCacheKey(candidate.path, current) === candidate.cacheKey) {
this.importDescriptorCache.set(candidate.cacheKey, structuredClone(descriptor));
}
}
return { type: "parsed", descriptor: descriptor ? structuredClone(descriptor) : null };
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
// Carry a `/rename` onto the imported agent so the name chosen in the CLI
// survives the import. Only an explicit rename counts: agents are named
// after their first prompt line since #1563, so adopting the generated
// `ai-title` here would put that LLM summary back.
const renamedTitle = await readClaudeRenamedSessionTitle({
cwd: input.cwd,
sessionId: input.providerHandleId,
configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)),
});
return importSessionFromPersistence({
provider: "claude",
request: input,
context,
resumeSession: this.resumeSession.bind(this),
...(renamedTitle ? { config: { title: renamedTitle } } : {}),
});
}
async isAvailable(): Promise<boolean> {
const launch = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
return availability.available;
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const launch = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
const auth = availability.available
? await resolveClaudeAuth(launch, availability, this.runtimeSettings)
: null;
return {
diagnostic: formatProviderDiagnostic("Claude Code", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["claude"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
...(auth ? [{ label: "Auth", value: auth }] : []),
]),
};
} catch (error) {
return {
diagnostic: formatProviderDiagnosticError("Claude Code", error),
};
}
}
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
if (config.provider !== "claude") {
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
}
const model = config.model?.trim();
const providerOptions =
validateProviderOptions("claude", ClaudeProviderOptionsSchema, config.providerOptions) ?? {};
return {
...config,
provider: "claude",
model: model || undefined,
providerOptions,
};
}
}
async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
if (availability.available) {
return availability.resolvedPath ?? launch.command;
}
throw new Error(
"Claude binary not found. Install Claude Code (https://github.com/anthropics/claude-code) and ensure it is available in your shell PATH.",
);
}
export async function resolveClaudeCodeVersion(
runtimeSettings?: ProviderRuntimeSettings,
signal?: AbortSignal,
): Promise<string> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.available) {
throw new Error("Claude binary not found while resolving Claude Code version");
}
const executable = availability.resolvedPath ?? launch.command;
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000,
signal,
});
// A wrapper command (the `command` provider setting) prints its own version here
// instead of Claude Code's. Only trust output that identifies itself as Claude Code,
// so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion.
const rawVersionOutput = `${stdout}\n${stderr}`;
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
? parseClaudeCodeVersion(rawVersionOutput)
: null;
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
return version.join(".");
}
async function resolveClaudeAuth(
launch: ResolvedProviderLaunch,
availability: { resolvedPath: string | null },
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
const run = async (
executable: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> => {
try {
return await execCommand(executable, args, {
...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000,
});
} catch (error) {
const err = toObjectRecord(error);
const stdout = typeof err?.stdout === "string" ? err.stdout : "";
const stderr = typeof err?.stderr === "string" ? err.stderr : "";
const fallbackMessage = typeof err?.message === "string" ? err.message : "";
return { stdout, stderr: stderr || fallbackMessage };
}
};
try {
const executable = availability.resolvedPath ?? launch.command;
const result = await run(executable, [...launch.args, "auth", "status"]);
const combined = [result.stdout, result.stderr]
.map((s) => s.trim())
.filter((s) => s.length > 0)
.join("\n");
return combined || null;
} catch {
return null;
}
}
function extractContextWindowSize(
modelUsage: unknown,
modelId: string | null | undefined,
): number | undefined {
const usageRecord = toObjectRecord(modelUsage);
if (!usageRecord) {
return undefined;
}
// modelUsage accumulates every model used in the session, including earlier turns
// and subagents. Only the active model describes this turn's context capacity.
const modelUsageEntry = modelId ? usageRecord[modelId] : undefined;
const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow;
return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
? contextWindow
: undefined;
}
interface StreamRequestInputUsage {
inputTokens: number;
cacheCreationInputTokens: number;
cacheReadInputTokens: number;
}
function readUsageCount(usage: Record<string, unknown>, key: string): number | undefined {
const value = usage[key];
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
function readStreamRequestInputUsage(
event: Record<string, unknown>,
): StreamRequestInputUsage | undefined {
const usage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!usage) {
return undefined;
}
const inputTokens = readUsageCount(usage, "input_tokens");
if (inputTokens === undefined) {
return undefined;
}
return {
inputTokens,
cacheCreationInputTokens: readUsageCount(usage, "cache_creation_input_tokens") ?? 0,
cacheReadInputTokens: readUsageCount(usage, "cache_read_input_tokens") ?? 0,
};
}
/**
* The input counters on `message_delta.usage` are cumulative whole-request totals and are
* optional. Mirror the Anthropic SDK's MessageStream: a counter present on the delta replaces
* the `message_start` value, a missing counter keeps it. Translating gateways report
* `input_tokens: 0` on `message_start` and only know the real count by `message_delta`.
*/
function mergeStreamDeltaInputUsage(
current: StreamRequestInputUsage,
event: Record<string, unknown>,
): StreamRequestInputUsage {
const usage = toObjectRecord(event.usage);
if (!usage) {
return current;
}
return {
inputTokens: readUsageCount(usage, "input_tokens") ?? current.inputTokens,
cacheCreationInputTokens:
readUsageCount(usage, "cache_creation_input_tokens") ?? current.cacheCreationInputTokens,
cacheReadInputTokens:
readUsageCount(usage, "cache_read_input_tokens") ?? current.cacheReadInputTokens,
};
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
const outputTokens = toObjectRecord(event.usage)?.output_tokens;
if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) {
return undefined;
}
return outputTokens;
}
function readLastUsageIteration(usage: unknown): Record<string, unknown> | undefined {
const iterations = toObjectRecord(usage)?.iterations;
if (!Array.isArray(iterations)) {
return undefined;
}
for (let index = iterations.length - 1; index >= 0; index -= 1) {
const candidate = toObjectRecord(iterations[index]);
if (candidate) {
return candidate;
}
}
return undefined;
}
function readUsageTokenTotal(usage: Record<string, unknown>): number | undefined {
const usageWithCacheCreation = usage as typeof usage & {
cache_creation_input_tokens?: unknown;
};
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: 0;
const cacheCreationInputTokens =
typeof usageWithCacheCreation.cache_creation_input_tokens === "number" &&
Number.isFinite(usageWithCacheCreation.cache_creation_input_tokens)
? usageWithCacheCreation.cache_creation_input_tokens
: 0;
const cacheReadInputTokens =
typeof usage.cache_read_input_tokens === "number" &&
Number.isFinite(usage.cache_read_input_tokens)
? usage.cache_read_input_tokens
: 0;
const outputTokens =
typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens)
? usage.output_tokens
: 0;
const total = inputTokens + cacheCreationInputTokens + cacheReadInputTokens + outputTokens;
return total > 0 ? total : undefined;
}
function readActiveUsageTokens(usage: unknown): number | undefined {
const activeUsage = readLastUsageIteration(usage);
return activeUsage ? readUsageTokenTotal(activeUsage) : undefined;
}
function readLegacyResultUsageTokens(usage: unknown): number | undefined {
const usageRecord = toObjectRecord(usage);
return usageRecord ? readUsageTokenTotal(usageRecord) : undefined;
}
function isClaudeSubagentToolName(name: string | undefined): boolean {
return name === "Task" || name === "Agent" || name === "Workflow";
}
function readClaudeParentToolUseId(message: SDKMessage): string | null {
if (!("parent_tool_use_id" in message)) {
return null;
}
// Claude Code's liveness tick for a tool still running after 30 s names that tool as its
// parent, so a long main-thread call would otherwise read as a subagent.
if (message.type === "tool_progress" && message.heartbeat === true) {
return null;
}
const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id;
return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null;
}
class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputUsage: StreamRequestInputUsage | undefined;
private streamRequestOutputTokens: number | undefined;
private compactedContextWindowUsedTokens: number | undefined;
private completedResultTurns = 0;
constructor(initialContextWindowMaxTokens?: number) {
this.contextWindowMaxTokens = initialContextWindowMaxTokens;
}
beginTurn(): void {
this.streamRequestInputUsage = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = undefined;
}
setInitialContextWindowMaxTokens(contextWindowMaxTokens: number | undefined): void {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId);
if (contextWindowMaxTokens !== undefined) {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
return this.contextWindowMaxTokens;
}
buildStreamUsageEvent(event: unknown): AgentStreamEvent | null {
const streamEvent = toObjectRecord(event);
if (!streamEvent) {
return null;
}
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputUsage = readStreamRequestInputUsage(streamEvent);
if (!inputUsage) {
return null;
}
this.streamRequestInputUsage = inputUsage;
this.streamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.streamRequestOutputTokens = outputTokens;
if (this.streamRequestInputUsage) {
this.streamRequestInputUsage = mergeStreamDeltaInputUsage(
this.streamRequestInputUsage,
streamEvent,
);
}
} else {
return null;
}
const usedTokens = this.streamUsedTokens();
if (usedTokens === undefined) {
return null;
}
return this.createUsageUpdatedEvent(usedTokens);
}
buildResultUsage(
message: SDKResultMessage,
modelId: string | null | undefined,
): AgentUsage | undefined {
try {
if (!message.usage) {
return undefined;
}
const usage: AgentUsage = {
inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId);
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
} else if (modelContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = modelContextWindowMaxTokens;
}
const activeResultUsageTokens =
readActiveUsageTokens(message.usage) ??
(this.completedResultTurns === 0 ? readLegacyResultUsageTokens(message.usage) : undefined);
const usedTokens =
this.streamUsedTokens() ?? activeResultUsageTokens ?? this.compactedContextWindowUsedTokens;
if (usedTokens !== undefined) {
usage.contextWindowUsedTokens = usedTokens;
}
return usage;
} finally {
this.compactedContextWindowUsedTokens = undefined;
this.completedResultTurns += 1;
}
}
private streamUsedTokens(): number | undefined {
const inputUsage = this.streamRequestInputUsage;
if (!inputUsage || typeof this.streamRequestOutputTokens !== "number") {
return undefined;
}
const usedTokens =
inputUsage.inputTokens +
inputUsage.cacheCreationInputTokens +
inputUsage.cacheReadInputTokens +
this.streamRequestOutputTokens;
return usedTokens > 0 ? usedTokens : undefined;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
this.streamRequestInputUsage = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = postTokens;
const usage: AgentUsage = {};
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
}
if (postTokens !== undefined) {
usage.contextWindowUsedTokens = postTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
}
class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
private readonly logger: Logger;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private readonly runtimeSafety = new ClaudeRuntimeSafety();
private readonly backgroundStops = new Map<string, (outcome: string) => void>();
private processingSdkMessages = 0;
private readonly harnessEnvironment: Record<string, string>;
private readonly usageSessionKey = randomUUID();
private childProcess: ChildProcess | null = null;
private input: AsyncMessageInput<SDKUserMessage> | null = null;
/** The exact SDK query/input pair that owns the current foreground turn. */
private activeForegroundQuery: Query | null = null;
private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null;
/**
* Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose
* command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK
* dequeues one and resumes the turn we just stopped. SDK user UUIDs are provider-private; never
* let them escape the adapter boundary.
*/
private readonly unstartedMessageUuids = new Set<string>();
/** Human steers whose text has not reached Claude yet and therefore supersede blocking cards. */
private readonly permissionClearingSteerUuids = new Set<string>();
private claudeSessionId: string | null;
private persistence: AgentPersistenceHandle | null;
private currentMode: PermissionMode;
private planResumeMode: PermissionMode | null = null;
private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private subagentHandbackIds = new Set<string>();
private pendingPermissions = new Map<string, PendingPermission>();
private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly timelineAssembler = new TimelineAssembler();
private readonly taskState = new ClaudeTaskState();
private readonly taskProtocolSource = new ClaudeTaskProtocolSource({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
readWorkflowResult: readClaudeWorkflowResultFile,
});
/**
* Subagent tool calls Claude has already answered while the child still runs, with the input
* they were launched with and the turn that answered them. A background child is answered at
* launch and streams its frames afterwards.
*/
private readonly settledSubagentToolCalls = new Map<
string,
{
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
input: AgentMetadata | null;
turnId: string | null;
}
>();
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCalls.get(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
// strings: it reacts to what this session actually does.
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// Late card updates retain the turn that answered the call.
needsSyntheticParentToolCard: (toolUseId) =>
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
AgentStreamEvent,
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
private historyReady: Promise<void> | null = null;
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
private lastRuntimeModel: string | null = null;
private compacting = false;
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
/** True from the moment a query's launch options are copied until the query exists. */
private queryStarting = false;
private pendingInterruptAbort = false;
/**
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
* pushed message's command_lifecycle "started" (/compact works for seconds before its init),
* cleared by the turn's result or an idle session state. See observeMainTurnBracket.
*/
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
private readonly rewindSdk: ClaudeRewindSdk;
private pendingFreshSessionId: string | null = null;
private recentStderr = "";
private closed = false;
readonly openOptionsPatch: Record<string, unknown> = {};
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
assertClaudeThinkingOptionSupported(config.model, config.thinkingOptionId);
this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.harnessEnvironment = this.buildSdkEnv();
this.persistSession = options.persistSession;
this.logger = options.logger.child({ agentId: this.agentId });
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
this.contextUsage = new ClaudeContextUsageState(
getClaudeContextWindowMaxTokens(this.config.model),
);
const handle = options.handle;
if (handle) {
if (!handle.sessionId) {
throw new Error("Cannot resume: persistence handle has no sessionId");
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.historySessionId = handle.sessionId;
} else {
this.claudeSessionId = null;
this.persistence = null;
}
// Validate mode if provided
if (config.modeId && !VALID_CLAUDE_MODES.has(config.modeId)) {
const validModesList = Array.from(VALID_CLAUDE_MODES).join(", ");
throw new Error(
`Invalid mode '${config.modeId}' for Claude provider. Valid modes: ${validModesList}`,
);
}
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
// Fast cannot run on this model, so the session opens with it off. Off rather than removed
// keeps a `config.json` default of `fast_mode: true` from filling it back in.
if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) {
this.config.providerOptions = { ...config.providerOptions, fast_mode: false };
this.openOptionsPatch = { fast_mode: false };
}
}
get id(): string | null {
return this.claudeSessionId;
}
get controls(): AgentOptionControl[] {
return buildClaudeControls(this.config.model);
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) {
return { ...this.cachedRuntimeInfo };
}
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel ?? this.config.model ?? null,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
extra: {
runtimeModel: this.lastRuntimeModel,
},
}
: {}),
};
this.cachedRuntimeInfo = info;
return { ...info };
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const result = await runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: () => this.claudeSessionId ?? "",
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
});
this.cachedRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
modeId: this.currentMode ?? null,
};
if (!this.claudeSessionId) {
throw new Error("Session ID not set after run completed");
}
return result;
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
const slashCommand = this.resolveSlashCommandInvocation(prompt);
if (slashCommand?.commandName === REWIND_COMMAND_NAME) {
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.transitionTurnState("foreground", "rewind command");
void this.executeRewindTurn(turnId, slashCommand);
return { turnId };
}
if (this.autonomousTurn) {
this.completeAutonomousTurn();
}
const sdkMessage = this.toSdkUserMessage(prompt);
const sdkUserMessageId =
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0 ? sdkMessage.uuid : null;
this.rememberRewindUserAnchor(sdkUserMessageId);
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
if (sdkUserMessageId) this.unstartedMessageUuids.add(sdkUserMessageId);
let cancelIssued = false;
const requestCancel = () => {
if (cancelIssued) {
return;
}
cancelIssued = true;
if (this.cancelCurrentTurn === requestCancel) {
this.cancelCurrentTurn = null;
}
// Whether Claude has this message in hand. A late idle from the previous turn can clear
// mainTurnInFlight after Claude started it, so the message's own lifecycle counts too.
const claudeStartedTurn =
this.mainTurnInFlight ||
(sdkUserMessageId !== null && !this.unstartedMessageUuids.has(sdkUserMessageId));
this.rejectAllPendingPermissions(new Error("Permission request canceled"));
this.finishForegroundTurn({
type: "turn_canceled",
provider: "claude",
reason: "Interrupted",
});
void this.interruptActiveTurn(claudeStartedTurn).catch((error) => {
this.logger.warn({ err: error }, "Failed to interrupt during cancel");
});
};
this.cancelCurrentTurn = requestCancel;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
try {
await this.ensureQuery();
if (cancelIssued) {
// Stopped while Claude was still starting up: withdrawn by never sending it.
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
return { turnId };
}
if (!this.input) {
throw new Error("Claude session input stream not initialized");
}
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
}
}, 0);
} catch (error) {
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
this.finishForegroundTurn(
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
);
}
return { turnId };
}
async steerActiveTurn(
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
if (this.resolveSlashCommandInvocation(prompt)) {
return { status: "unavailable" };
}
const activeTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
if (this.compacting || activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
// Capture both ends of the live SDK stream before creating or delivering the message. There
// is deliberately no await below: a finished A cannot make this input point at a later B.
const query = this.activeForegroundQuery;
const input = this.activeForegroundInput;
if (!query || !input || this.query !== query || this.input !== input) {
return { status: "unavailable" };
}
const message = this.toSdkUserMessage(prompt);
message.priority = "next";
if (
(this.activeForegroundTurnId ?? this.autonomousTurn?.id) !== options.expectedTurnId ||
this.activeForegroundQuery !== query ||
this.activeForegroundInput !== input ||
this.query !== query ||
this.input !== input
) {
return { status: "unavailable" };
}
this.enqueueSteer(input, message, options.clearPendingPermissions === true);
return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) };
}
private enqueueSteer(
input: AsyncMessageInput<SDKUserMessage>,
message: SDKUserMessage,
clearPendingPermissions: boolean,
): void {
const uuid = message.uuid;
if (uuid) this.unstartedMessageUuids.add(uuid);
if (uuid && clearPendingPermissions) {
this.permissionClearingSteerUuids.add(uuid);
}
try {
input.push(message);
if (clearPendingPermissions) {
this.denyPendingPermissionsSupersededBySteer();
}
} catch (error) {
if (uuid) {
this.unstartedMessageUuids.delete(uuid);
this.permissionClearingSteerUuids.delete(uuid);
}
throw error;
}
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async stopBackgroundWork(id: string): Promise<void> {
const query = this.query;
if (!query || !this.runtimeSafety.canStop(id))
throw new Error("Background work is no longer available");
if (this.backgroundStops.has(id)) throw new Error("Stop is already pending");
let timer: ReturnType<typeof setTimeout> | undefined;
let finish!: (outcome: string) => void;
const outcome = new Promise<string>((resolve) => {
finish = resolve;
this.backgroundStops.set(id, resolve);
});
try {
const result = await Promise.race([
Promise.all([query.stopTask(id), outcome]).then(([, value]) => value),
outcome.then((value) => (value === "stopped" ? new Promise<string>(() => {}) : value)),
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve("timeout"), 15000);
}),
]);
if (result !== "stopped") throw new Error(`Background work stop: ${result}`);
if (this.query !== query) throw new Error("Background work connection changed");
} finally {
clearTimeout(timer);
if (this.backgroundStops.get(id) === finish) this.backgroundStops.delete(id);
}
}
async interrupt(): Promise<void> {
if (this.cancelCurrentTurn) {
this.cancelCurrentTurn();
return;
}
if (this.autonomousTurn) {
this.flushPendingToolCalls();
this.completeAutonomousTurn();
}
await this.interruptActiveTurn();
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
) {
return;
}
const history = this.persistedHistory;
const providerSubagentEvents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
let replayed = 0;
for (const entry of history) {
if (++replayed % 32 === 0) await setImmediate();
yield {
type: "timeline",
item: entry.item,
provider: "claude",
timestamp: entry.timestamp,
};
}
yield* providerSubagentEvents;
}
async getAvailableModes(): Promise<AgentMode[]> {
return this.availableModes;
}
async getCurrentMode(): Promise<string | null> {
return this.currentMode ?? null;
}
async setMode(modeId: string): Promise<void> {
// Validate mode
if (!VALID_CLAUDE_MODES.has(modeId)) {
const validModesList = Array.from(VALID_CLAUDE_MODES).join(", ");
throw new Error(
`Invalid mode '${modeId}' for Claude provider. Valid modes: ${validModesList}`,
);
}
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const previousQuery = this.query;
const activeQuery = await this.ensureQuery(normalized);
try {
await activeQuery.setPermissionMode(normalized);
} catch (error) {
// The query was launched in the rejected mode; relaunch in the current mode next time.
if (activeQuery !== previousQuery) this.queryRestartNeeded = true;
throw error;
}
if (normalized === "plan") {
if (previousMode !== "plan") {
this.planResumeMode = previousMode;
}
} else {
this.planResumeMode = normalized;
}
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
await activeQuery.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.reconcileThinkingOptionForModel(normalizedModelId);
if (this.config.providerOptions.fast_mode) {
const supported = claudeModelSupportsFastMode(this.config.model);
await activeQuery.applyFlagSettings({ fastMode: supported });
if (!supported) {
// Fast cannot run on this model. Reporting it off rather than removed keeps a
// `config.json` default of `fast_mode: true` from filling it back in.
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: false };
this.pushEvent({
type: "options_changed",
provider: "claude",
patch: { fast_mode: false },
});
}
this.cachedRuntimeInfo = null;
}
this.contextUsage.setInitialContextWindowMaxTokens(
getClaudeContextWindowMaxTokens(this.config.model),
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
return { model: this.lastOptionsModel ?? this.config.model ?? null };
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
const thinkingOptionId = this.config.thinkingOptionId;
if (thinkingOptionId !== CLAUDE_DISABLED_THINKING_OPTION_ID) {
return;
}
const resolution = resolveClaudeDisabledThinkingForModel(modelId);
if (resolution.supported) {
return;
}
this.config.thinkingOptionId = resolution.fallbackThinkingOptionId;
this.queryRestartNeeded = true;
this.pushEvent({
type: "thinking_option_changed",
provider: "claude",
thinkingOptionId: this.config.thinkingOptionId ?? null,
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
this.config.thinkingOptionId = undefined;
} else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
assertClaudeThinkingOptionSupported(this.config.model, normalizedThinkingOptionId);
this.config.thinkingOptionId = normalizedThinkingOptionId;
} else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
this.queryRestartNeeded = true;
if (this.activeForegroundTurnId || this.autonomousTurn) {
return THINKING_APPLIES_NEXT_TURN_NOTICE;
}
}
async applyOptions(options: ProviderOptions): Promise<void> {
// Every option but Fast is read when the query is built, and replacing a started query would
// end its background work, so once it exists only Fast can change.
if (this.query || this.queryStarting) {
assertChangesAreLive({
provider: "claude",
patch: options,
current: this.config.providerOptions,
live: ["fast_mode"],
});
}
const providerOptions =
validateProviderOptions(
"claude",
ClaudeProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
) ?? {};
if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) {
throw new Error(
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
);
}
const fastModeChanged =
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
if (fastModeChanged) {
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
this.cachedRuntimeInfo = null;
}
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = providerOptions;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
}
/**
* Settle the original call immediately, before waiting for the SDK tool result.
* Plan resolution must use the native call ID so it cannot move past a follow-up.
*/
private recordDeniedPermissionTimeline(
request: AgentPermissionRequest,
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
): void {
if (request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: request.name,
callId:
(typeof request.metadata?.toolUseId === "string" ? request.metadata.toolUseId : null) ??
request.id,
input: request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
return;
}
if (request.kind === "plan") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: "ExitPlanMode",
callId: this.planToolCallId(request),
input: request.input,
error: response.message ?? "Permission denied",
metadata: { actionId: response.selectedActionId ?? "reject" },
}),
);
}
}
private planToolCallId(request: AgentPermissionRequest): string {
return typeof request.metadata?.toolUseId === "string"
? request.metadata.toolUseId
: request.id;
}
private resolveDeniedPermission(
request: AgentPermissionRequest,
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
): PermissionResult {
this.recordDeniedPermissionTimeline(request, response);
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId: request.id,
resolution: response,
});
return {
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
};
}
private denyPendingPermissionsSupersededBySteer(): void {
for (const [requestId, pending] of this.pendingPermissions) {
this.pendingPermissions.delete(requestId);
pending.cleanup?.();
pending.resolve(
this.resolveDeniedPermission(pending.request, {
behavior: "deny",
message: STEER_SUPERSEDED_PERMISSION_MESSAGE,
}),
);
}
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
const pending = this.pendingPermissions.get(requestId);
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
// Before the delete below, so a rejected answer leaves the request pending
// and answerable rather than consuming it for nothing. AgentManager only
// drops its own copy once this resolves, so both maps survive the throw.
assertClaudeQuestionAnswerDeliverable(pending.request, response);
this.pendingPermissions.delete(requestId);
pending.cleanup?.();
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const resumeMode = this.planResumeMode;
const shouldResumePriorMode =
selectedActionId === "implement_resume" &&
(resumeMode === "bypassPermissions" || resumeMode === "auto");
const targetMode: PermissionMode = shouldResumePriorMode ? resumeMode : "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "ExitPlanMode",
callId: this.planToolCallId(pending.request),
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
const result: PermissionResult = {
behavior: "allow",
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
} else {
pending.resolve(this.resolveDeniedPermission(pending.request, response));
return;
}
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: response,
});
}
describePersistence(): AgentPersistenceHandle | null {
if (this.persistence) {
return this.persistence;
}
if (!this.claudeSessionId) {
return null;
}
const { providerOptions: _providerOptions, ...persistedConfig } = this.config;
this.persistence = {
provider: "claude",
sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId,
metadata: { ...persistedConfig },
};
return this.persistence;
}
async canStopRuntime(): Promise<boolean> {
return (
!this.activeForegroundTurnId &&
!this.autonomousTurn &&
!this.mainTurnInFlight &&
this.unstartedMessageUuids.size === 0 &&
this.pendingPermissions.size === 0 &&
this.processingSdkMessages === 0 &&
this.runtimeSafety.canStopRuntime()
);
}
async close(): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"provider.claude.session_close.start",
);
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
// AgentManager settles the run after runtime release. Preserve cleanup records
// without reporting this closure as an explicit turn cancellation.
this.flushPendingToolCalls();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.autonomousTurn = null;
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.sidechainTracker.clear();
this.settledSubagentToolCalls.clear();
this.taskProtocolSource.reset();
this.input?.end();
this.query?.close?.();
// close() already terminates the SDK query. Its control plane is no longer
// available; waiting for interrupt/return here delays our process-tree cleanup
// beyond the daemon's close deadline when the provider stops answering.
this.query = null;
this.input = null;
// Terminate the entire process tree (claude + MCP children) to prevent
// orphan accumulation. The SDK's internal cleanup may only kill the
// direct child process.
if (this.childProcess) {
const result = await terminateWithTreeKill(this.childProcess, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
if (result === "kill-timeout") {
this.logger.warn(
{ pid: this.childProcess.pid, agentId: this.agentId },
"Claude process tree did not report exit after SIGKILL",
);
}
this.childProcess = null;
}
if (this.persistSession === false && this.claudeSessionId) {
// Claude Code currently ignores --no-session-persistence outside --print mode
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
// (metadata generator, branch-name generator) don't show up as resumable.
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
} catch (error) {
this.logger.warn(
{ err: error, historyPath, claudeSessionId: this.claudeSessionId },
"Failed to delete ephemeral Claude session transcript",
);
}
}
}
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnState: this.turnState,
},
"provider.claude.session_close.complete",
);
}
async listCommands(): Promise<AgentSlashCommand[]> {
const q = await this.ensureQuery();
const commands = await q.supportedCommands();
const commandMap = new Map<string, AgentSlashCommand>();
for (const cmd of commands) {
if (isClaudeInternalSlashCommand(cmd.name)) continue;
if (!commandMap.has(cmd.name)) {
commandMap.set(cmd.name, {
name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
kind: classifyClaudeSlashCommand(cmd.name),
});
}
}
if (!commandMap.has(REWIND_COMMAND_NAME)) {
commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND);
}
return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name));
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = await this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
return;
}
const currentSessionId = this.claudeSessionId;
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
messageId,
});
}
async revertBoth(input: { messageId: string }): Promise<void> {
await this.revertFiles(input);
await this.revertConversation(input);
}
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
if (typeof prompt !== "string") {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed) {
return null;
}
return parsed.commandName === REWIND_COMMAND_NAME ||
CLAUDE_ROOT_ONLY_COMMANDS.has(parsed.commandName)
? parsed
: null;
}
private parseSlashCommandInput(text: string): SlashCommandInvocation | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
return null;
}
const withoutPrefix = trimmed.slice(1);
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
const commandName =
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
if (!commandName || commandName.includes("/")) {
return null;
}
const rawArgs =
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
return rawArgs.length > 0
? { commandName, args: rawArgs, rawInput: trimmed }
: { commandName, rawInput: trimmed };
}
private buildRewindSuccessMessage(
targetUserMessageId: string,
rewindResult: {
filesChanged?: string[];
insertions?: number;
deletions?: number;
},
): string {
const fileCount = Array.isArray(rewindResult.filesChanged)
? rewindResult.filesChanged.length
: undefined;
const stats: string[] = [];
if (typeof fileCount === "number") {
stats.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`);
}
if (typeof rewindResult.insertions === "number") {
stats.push(`${rewindResult.insertions} insertions`);
}
if (typeof rewindResult.deletions === "number") {
stats.push(`${rewindResult.deletions} deletions`);
}
if (stats.length > 0) {
return `Rewound tracked files to message ${targetUserMessageId} (${stats.join(", ")}).`;
}
return `Rewound tracked files to message ${targetUserMessageId}.`;
}
private async attemptRewind(args: string | undefined): Promise<{
messageId: string | null;
result?: {
filesChanged?: string[];
insertions?: number;
deletions?: number;
};
error?: string;
}> {
if (typeof args === "string" && args.trim().length > 0) {
const candidate = args.trim().split(/\s+/)[0] ?? "";
if (!UUID_PATTERN.test(candidate)) {
return {
messageId: null,
error: "Invalid message UUID. Usage: /rewind <user_message_uuid> or /rewind",
};
}
const rewindResult = await this.rewindFilesOnce(candidate);
if (rewindResult.canRewind) {
return { messageId: candidate, result: rewindResult };
}
return {
messageId: null,
error: rewindResult.error ?? `No file checkpoint found for message ${candidate}.`,
};
}
const candidates = this.getRewindCandidateUserMessageIds();
if (candidates.length === 0) {
return {
messageId: null,
error: "No prior user message available to rewind. Use /rewind <user_message_uuid>.",
};
}
let lastError: string | undefined;
for (const candidate of candidates) {
try {
const rewindResult = await this.rewindFilesOnce(candidate);
if (rewindResult.canRewind) {
return { messageId: candidate, result: rewindResult };
}
if (rewindResult.error) {
lastError = rewindResult.error;
}
} catch (error) {
lastError = error instanceof Error ? error.message : "Failed to rewind tracked files.";
}
}
return {
messageId: null,
error: lastError ?? "No rewind checkpoints are currently available for this session.",
};
}
private async rewindFilesOnce(messageId: string): Promise<{
canRewind: boolean;
error?: string;
filesChanged?: string[];
insertions?: number;
deletions?: number;
}> {
try {
const activeQuery = await this.ensureFreshQuery();
return await activeQuery.rewindFiles(messageId, { dryRun: false });
} catch (error) {
// The Claude SDK transport can close after a rewind call.
// If that happens, mark the query stale so a follow-up attempt uses a fresh query.
this.queryRestartNeeded = true;
throw error;
}
}
private async ensureFreshQuery(): Promise<Query> {
if (this.query) {
this.queryRestartNeeded = true;
}
return this.ensureQuery();
}
private getRewindCandidateUserMessageIds(): string[] {
const candidates: string[] = [];
const pushUnique = (value: string | null | undefined) => {
if (typeof value === "string" && value.length > 0 && !candidates.includes(value)) {
candidates.push(value);
}
};
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
const entry = this.persistedHistory[idx];
if (entry?.item.type === "user_message") {
pushUnique(entry.item.messageId);
}
}
for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(this.userMessageIds[idx]);
}
return candidates;
}
private rebindConversationSession(sessionId: string): Promise<void> {
const previousReady = this.ensureHistoryReady();
const rebind = (async () => {
await previousReady;
await this.retireQuery();
if (this.closed) return;
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historySessionId = sessionId;
await this.loadPersistedHistory(sessionId);
if (this.closed) return;
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
})();
this.historyReady = rebind.catch((error) => {
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
});
return rebind;
}
private startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
}
private rememberUserMessageId(messageId: string | null | undefined): void {
if (typeof messageId !== "string" || messageId.length === 0) {
return;
}
const last = this.userMessageIds[this.userMessageIds.length - 1];
if (last === messageId) {
return;
}
this.userMessageIds.push(messageId);
}
private rememberEmittedUserMessageId(messageId: string | null | undefined): void {
if (typeof messageId !== "string" || messageId.length === 0) {
return;
}
this.emittedUserMessageIds.add(messageId);
}
private rememberRewindUserAnchor(userMessageId: string | null | undefined): void {
if (typeof userMessageId !== "string" || userMessageId.length === 0) {
return;
}
if (this.rewindTurnAnchors.some((anchor) => anchor.userMessageId === userMessageId)) {
return;
}
this.rewindTurnAnchors.push({
userMessageId,
assistantMessageId: null,
});
}
private rememberRewindAssistantAnchor(assistantMessageId: string | null | undefined): void {
if (typeof assistantMessageId !== "string" || assistantMessageId.length === 0) {
return;
}
for (let index = this.rewindTurnAnchors.length - 1; index >= 0; index -= 1) {
const anchor = this.rewindTurnAnchors[index];
if (!anchor) {
continue;
}
anchor.assistantMessageId = assistantMessageId;
return;
}
}
private rememberTranscriptProgress(message: SDKMessage, messageId: string | null): void {
if (!messageId) {
return;
}
// Subagent frames ride the same stream as the conversation, but their uuids live on the
// subagent's sidechain, so forkSession cannot resolve one. Anchoring a turn on one leaves
// rewind throwing "Message <uuid> not found in session". The persisted history path already
// skips sidechain entries; the live stream has to skip them too.
if (readClaudeParentToolUseId(message)) {
return;
}
if (
message.type === "user" &&
!isSyntheticUserEntry(message) &&
!isToolResultUserEntry(message)
) {
this.rememberRewindUserAnchor(messageId);
return;
}
if (message.type === "assistant") {
this.rememberRewindAssistantAnchor(messageId);
return;
}
if (message.type === "stream_event") {
const event = toObjectRecord(message.event) ?? {};
const eventType = readTrimmedString(event.type);
if (eventType === "message_start") {
this.rememberRewindAssistantAnchor(messageId);
}
return;
}
}
private resolveClaudeMessageId(messageId: string): string {
return messageId;
}
private async resolveConversationRewindTarget(
messageId: string,
): Promise<ClaudeConversationRewindTarget> {
const targetUserMessageId = this.resolveClaudeMessageId(messageId);
const index = this.rewindTurnAnchors.findIndex(
(anchor) => anchor.userMessageId === targetUserMessageId,
);
if (index < 0) {
// A message steered into a running turn opens no turn of its own.
const transcriptPath = this.claudeSessionId
? await this.resolveHistoryPath(this.claudeSessionId)
: null;
const steerParent = transcriptPath
? await findSteeredMessageParent(transcriptPath, targetUserMessageId)
: null;
if (steerParent) {
return { kind: "fork", messageId: steerParent };
}
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
// A turn the model never answered — an instant gateway error, or an abort before the first
// token — leaves its anchor without an assistant id, and carries no conversation state worth
// preserving. Fork at the most recent turn before the target that did answer. Reading only
// the immediately preceding anchor let one such turn fail every later rewind in the
// conversation.
for (let previous = index - 1; previous >= 0; previous -= 1) {
const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId;
if (assistantMessageId) {
return { kind: "fork", messageId: assistantMessageId };
}
}
return { kind: "fresh-session" };
}
private async retireQuery(): Promise<void> {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
for (const finish of this.backgroundStops.values()) finish("connection lost");
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
await this.ensureHistoryReady();
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
return this.query;
}
if (this.queryRestartNeeded && this.query) {
await this.retireQuery();
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
// buildOptions copies the launch options before it waits, so from here they are fixed.
this.queryStarting = true;
let options: ClaudeOptions;
try {
options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
} catch (error) {
this.queryStarting = false;
throw error;
}
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
this.dispatchEvents([{ type: "background_work", provider: "claude", items: null }]);
// A fresh Claude process has no turn of its own in flight.
this.mainTurnInFlight = false;
this.query = claudeQuery(
{ prompt: input.iterable, options },
{
runtimeSettings: this.runtimeSettings,
launchEnv: this.launchEnv,
queryFactory: this.queryFactory,
onChildProcess: (child) => {
this.childProcess = child;
child.once("exit", (code, signal) => this.handleRuntimeExit(child, code, signal));
},
},
);
this.queryStarting = false;
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) {
await this.query.applyFlagSettings({ fastMode });
}
// Do not kick off background control-plane queries here. Methods like
// supportedCommands()/setPermissionMode() may execute immediately after
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
// control plane can cause those calls to wait behind supportedModels().
return this.query;
}
private async awaitWithTimeout(
promise: Promise<unknown> | undefined,
label: string,
): Promise<void> {
if (!promise) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.skip",
);
return;
}
const startedAt = Date.now();
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.start",
);
try {
await withTimeout(promise, 3_000, "timeout");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
durationMs: Date.now() - startedAt,
},
"provider.claude.query_operation.settled",
);
} catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
}
}
private resolveThinkingConfig(): {
thinking: ClaudeOptions["thinking"];
effort: ClaudeOptions["effort"];
ultracode: boolean;
} {
const thinkingOptionId =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
assertClaudeThinkingOptionSupported(this.config.model, thinkingOptionId);
if (thinkingOptionId === CLAUDE_DISABLED_THINKING_OPTION_ID) {
return { thinking: { type: "disabled" }, effort: undefined, ultracode: false };
}
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
return {
thinking: { type: "adaptive", display: "summarized" },
effort: "xhigh",
ultracode: true,
};
}
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
return {
thinking: { type: "adaptive", display: "summarized" },
effort: thinkingOptionId,
ultracode: false,
};
}
return { thinking: undefined, effort: undefined, ultracode: false };
}
private buildAppendedSystemPrompt(): string {
return (
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
);
}
usageSession() {
if (this.closed) return null;
return {
provider: "claude",
model: this.config.model,
env: this.harnessEnvironment,
sessionKey: this.usageSessionKey,
};
}
private buildSdkEnv() {
return createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [
this.launchEnv,
// Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes
// an autonomous turn (see consumeSessionStateChange).
{ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" },
],
});
}
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
// `fast_mode` is a composer control the provider applies through flag settings.
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
const claudeBinary = await this.resolveBinary();
this.logger.debug(
{
claudeBinary,
pathEnvKey: resolvePathEnvKey(),
pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
const sessionBinding: Pick<ClaudeOptions, "resume" | "sessionId"> = {};
if (this.pendingFreshSessionId) {
sessionBinding.sessionId = this.pendingFreshSessionId;
} else if (this.claudeSessionId) {
sessionBinding.resume = this.claudeSessionId;
}
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
permissionMode,
// Dynamic mode switching can recreate the underlying Claude query. Keep the
// bypass launch capability available so later setPermissionMode("bypassPermissions")
// calls do not fail after a model/thinking/rewind-driven restart.
allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary,
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompts for agents.
systemPrompt: {
type: "preset",
preset: "claude_code",
append: appendedSystemPrompt,
},
settingSources: CLAUDE_SETTING_SOURCES,
stderr: (data: string) => {
this.captureStderr(data);
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
},
// Required for provider-level /rewind support.
enableFileCheckpointing: true,
// If we have a session ID from a previous query (e.g., after interrupt),
// resume that session to continue the conversation history.
...sessionBinding,
...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}),
...providerOptions,
...settingsOptions,
// Provider subagent panes render the child's nested transcript.
forwardSubagentText: true,
// Stop and replace abort only the main turn, like Esc in Claude Code, and background
// subagents keep running. Without this declaration an interrupt kills every one of them.
// Claude stops a helper itself with its TaskStop tool.
perTaskStopAffordance: true,
hooks: this.buildSubagentEffortHooks(),
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
env: sdkEnv,
};
if (this.config.mcpServers) {
base.mcpServers = this.normalizeMcpServers(this.config.mcpServers);
}
if (this.config.model) {
base.model = this.config.model;
}
this.lastOptionsModel = base.model ?? null;
if (this.claudeSessionId && !this.pendingFreshSessionId) {
base.resume = this.claudeSessionId;
}
if (this.runtimeSettings?.disallowedTools?.length) {
base.disallowedTools = [
...(base.disallowedTools ?? []),
...this.runtimeSettings.disallowedTools,
];
}
return base;
}
private async buildSettingsOptions(
providerOptions: ClaudeProviderOptions,
input: { ultracode: boolean },
): Promise<Pick<ClaudeOptions, "settings" | "extraArgs"> | Record<string, never>> {
const fastMode = this.resolveFastModeSetting();
// Internal agents do daemon work such as naming a branch, so the user's and
// project's hooks must not run for them.
const disableAllHooks = this.config.internal === true;
if (fastMode === null && !input.ultracode && !disableAllHooks) {
return {};
}
const updates = {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
};
// The SDK replaces extraArgs.settings with Options.settings, so a user's
// `--settings` would be dropped whenever Paseo sets flag settings. Fold it in.
const { settings: extraArgsSettings, ...extraArgs } = providerOptions.extraArgs ?? {};
if (typeof extraArgsSettings !== "string") {
return { settings: mergeClaudeSettings(providerOptions.settings, updates) };
}
const cliSettings = await readClaudeCliSettings(extraArgsSettings, this.config.cwd);
return {
extraArgs,
settings: { ...cliSettings, ...providerOptions.settings, ...updates },
};
}
private resolveFastModeSetting(): boolean | null {
if (!claudeModelSupportsFastMode(this.config.model)) {
return null;
}
return this.config.providerOptions.fast_mode === true;
}
private normalizeMcpServers(
servers: Record<string, McpServerConfig>,
): Record<string, ClaudeSdkMcpServerConfig> {
const result: Record<string, ClaudeSdkMcpServerConfig> = {};
for (const [name, config] of Object.entries(servers)) {
result[name] = toClaudeSdkMcpConfig(config);
}
return result;
}
private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage {
const content: Array<
| { type: "text"; text: string }
| {
type: "image";
source: {
type: "base64";
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
data: string;
};
}
> = [];
// Claude Code expands a slash command only when it is the last content block of the user
// message. buildAgentPrompt places the typed text ahead of images and rendered attachments,
// so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as
// literal text instead of the command it names.
let typedSlashCommandIndex = -1;
if (Array.isArray(prompt)) {
for (const chunk of prompt) {
if (chunk.type === "text") {
// Attachments rendered as text always carry a mimeType; the composer's typed text does
// not. Only the typed block can be a slash command the user meant to invoke.
if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) {
typedSlashCommandIndex = content.length;
}
content.push({ type: "text", text: chunk.text });
} else if (chunk.type === "image") {
if (isImageMimeType(chunk.mimeType)) {
content.push({
type: "image",
source: {
type: "base64",
media_type: chunk.mimeType,
data: chunk.data,
},
});
}
} else {
content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) });
}
}
} else {
content.push({ type: "text", text: prompt });
}
if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) {
const [slashCommand] = content.splice(typedSlashCommandIndex, 1);
content.push(slashCommand);
}
const messageId = randomUUID();
this.rememberUserMessageId(messageId);
return {
type: "user",
message: {
role: "user",
content,
},
parent_tool_use_id: null,
uuid: messageId,
session_id: this.claudeSessionId ?? "",
};
}
private transitionTurnState(next: TurnState, reason: string): void {
if (this.turnState === next) {
return;
}
this.logger.debug({ from: this.turnState, to: next, reason }, "Claude turn state transition");
this.turnState = next;
}
private syncTurnState(reason: string): void {
if (this.activeForegroundTurnId) {
this.transitionTurnState("foreground", reason);
return;
}
if (this.autonomousTurn) {
this.transitionTurnState("autonomous", reason);
return;
}
this.transitionTurnState("idle", reason);
}
private isAbortError(message: SDKMessage): boolean {
const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
return errors.some((e: string) => /\baborted\b/i.test(e));
}
private buildTurnFailedEvent(
errorMessage: string,
): Extract<AgentStreamEvent, { type: "turn_failed" }> {
const normalized = errorMessage.trim() || "Claude run failed";
const exitCodeMatch = normalized.match(/\bcode\s+(\d+)\b/i);
const code = exitCodeMatch ? exitCodeMatch[1] : undefined;
const diagnostic = this.getRecentStderrDiagnostic();
return {
type: "turn_failed",
provider: "claude",
error: normalized,
...(code ? { code } : {}),
...(diagnostic ? { diagnostic } : {}),
};
}
private captureStderr(data: string): void {
const text = data.trim();
if (!text) {
return;
}
const combined = this.recentStderr ? `${this.recentStderr}\n${text}` : text;
this.recentStderr = combined.slice(-MAX_RECENT_STDERR_CHARS);
}
private clearRecentStderr(): void {
this.recentStderr = "";
}
private getRecentStderrDiagnostic(): string | undefined {
return this.recentStderr.trim() || undefined;
}
private async awaitRecentStderrAfterProcessExit(error: unknown): Promise<void> {
if (this.getRecentStderrDiagnostic()) {
return;
}
const message = errorToMessageString(error);
if (
!/\bprocess exited with code\b/i.test(message) &&
!/\bterminated by signal\b/i.test(message)
) {
return;
}
const startedAt = Date.now();
while (!this.closed && !this.getRecentStderrDiagnostic()) {
if (Date.now() - startedAt >= STDERR_FLUSH_WAIT_MS) {
return;
}
await new Promise((resolve) => setTimeout(resolve, STDERR_FLUSH_POLL_INTERVAL_MS));
}
}
private createTurnId(owner: "foreground" | "autonomous"): string {
return `${owner}-turn-${this.nextTurnOrdinal++}`;
}
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
private async executeRewindTurn(
_turnId: string,
invocation: SlashCommandInvocation,
): Promise<void> {
this.notifySubscribers({ type: "turn_started", provider: "claude" });
try {
const rewindAttempt = await this.attemptRewind(invocation.args);
if (!rewindAttempt.messageId || !rewindAttempt.result) {
this.finishForegroundTurn({
type: "turn_failed",
provider: "claude",
error:
rewindAttempt.error ??
"No prior user message available to rewind. Use /rewind <user_message_uuid>.",
});
return;
}
this.notifySubscribers({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: this.buildRewindSuccessMessage(rewindAttempt.messageId, rewindAttempt.result),
},
});
this.finishForegroundTurn({ type: "turn_completed", provider: "claude" });
} catch (error) {
this.finishForegroundTurn({
type: "turn_failed",
provider: "claude",
error: error instanceof Error ? error.message : "Failed to rewind tracked files",
});
}
}
private shouldRecoverInterruptedQueryAbort(
error: unknown,
consecutiveRecoveries: number,
): boolean {
if (consecutiveRecoveries >= 3) {
return false;
}
let message: string;
if (typeof error === "string") {
message = error;
} else if (error instanceof Error) {
message = `${error.message}\n${error.stack ?? ""}`;
} else {
message = JSON.stringify(error);
}
return message.toLowerCase().includes("request was aborted");
}
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
if (event.type === "turn_failed" || event.type === "turn_canceled") {
this.flushPendingToolCalls();
}
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("foreground turn terminal");
}
private dispatchEvents(events: AgentStreamEvent[]): void {
let terminalSeen = false;
for (const event of events) {
this.notifySubscribers(event);
terminalSeen ||= this.isTerminalTurnEvent(event);
}
if (terminalSeen) {
this.compactionMarkerOpen = false;
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("autonomous turn terminal");
}
}
}
private startAutonomousTurn(): void {
if (this.autonomousTurn) {
return;
}
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.activeTurnHasAssistantText = false;
this.contextUsage.beginTurn();
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
private completeAutonomousTurn(): void {
if (!this.autonomousTurn) {
return;
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("autonomous turn completed");
}
private failActiveTurns(errorMessage: string): void {
const failure = this.buildTurnFailedEvent(errorMessage);
this.flushPendingToolCalls();
if (this.activeForegroundTurnId) {
this.finishForegroundTurn(failure);
return;
}
if (this.autonomousTurn) {
this.dispatchEvents([failure]);
}
}
// Background Bash shells, Monitor watches and workflows live inside the Claude
// Code process, so they die with it. When the process exits mid-turn the query
// pump reports it, but when it exits between turns nothing observes the death:
// the agent stays "idle" and the wake-back that would have continued the work
// never arrives.
private handleRuntimeExit(
child: ChildProcess,
code: number | null,
signal: NodeJS.Signals | null,
): void {
if (this.closed || this.childProcess !== child) {
return;
}
this.childProcess = null;
this.logger.warn(
{ agentId: this.agentId, pid: child.pid, code, signal },
"Claude runtime exited unexpectedly",
);
this.failRunningRuntimeTasks();
if (this.activeForegroundTurnId || this.autonomousTurn) {
// The pump is about to throw. It waits for stderr to flush and reports the
// real cause; reporting here first would replace that with a bare exit code
// and detach this.query, which is how the pump recognizes its own stream.
return;
}
// Drop the dead handles so the next write spawns a fresh process instead of
// failing against a dead transport forever.
this.query = null;
this.input = null;
this.dispatchEvents([
this.buildTurnFailedEvent(
`Claude stopped unexpectedly (${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}). Any background shells, monitors or other work it had running were terminated with it.`,
),
]);
}
private failRunningRuntimeTasks(): void {
for (const finish of this.backgroundStops.values()) finish("connection lost");
this.runtimeSafety.retire();
this.dispatchEvents([
...foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map(
(event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }),
),
// The SDK resets its background task set when the CLI process (re)starts.
{ type: "background_work", provider: "claude", items: null },
]);
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
return;
}
const pump = this.runQueryPump().catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.exit_unexpected",
);
});
this.queryPumpPromise = pump;
void pump.finally(() => {
if (this.queryPumpPromise === pump) {
this.queryPumpPromise = null;
}
});
}
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
rawEvent: message,
},
"provider.claude.raw_event",
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
const backgroundWork = this.runtimeSafety.observe(message);
if (message.type === "system" && message.subtype === "task_notification")
this.backgroundStops.get(message.task_id)?.(message.status);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
if (!this.ownsQuery(activeQuery)) return true;
if (backgroundWork && this.runtimeSafety.isCurrentInventory(backgroundWork))
this.dispatchEvents([
{ type: "background_work", provider: "claude", items: backgroundWork },
]);
return false;
} finally {
this.processingSdkMessages--;
}
};
const drainActiveQuery = async (): Promise<boolean> => {
for await (const message of activeQuery) {
if (await handlePumpedMessage(message)) {
return true;
}
}
return false;
};
try {
while (!this.closed && this.query === activeQuery) {
try {
if (await drainActiveQuery()) {
return;
}
if (!this.closed && this.query === activeQuery) {
this.failActiveTurns("Claude stream ended before terminal result");
}
return;
} catch (error) {
if (
!this.closed &&
this.query === activeQuery &&
this.shouldRecoverInterruptedQueryAbort(error, consecutiveInterruptAbortRecoveries)
) {
consecutiveInterruptAbortRecoveries += 1;
this.logger.debug(
{ recoveries: consecutiveInterruptAbortRecoveries },
"Recovering Claude query pump after interrupt abort",
);
continue;
}
if (!this.closed && this.query === activeQuery) {
await this.awaitRecentStderrAfterProcessExit(error);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
}
return;
}
}
} finally {
if (this.query === activeQuery) {
this.query = null;
this.input = null;
}
}
}
private shouldSuppressStaleResult(message: SDKMessage): boolean {
// Suppress stale results from interrupted requests. The cancel path already
// emitted the terminal event; this result is leftover from the killed API
// request. Consume the flag on ANY result so it doesn't linger.
if (message.type === "result" && this.pendingInterruptAbort) {
this.pendingInterruptAbort = false;
if (message.subtype !== "success") {
this.logger.debug("Suppressing stale non-success result from interrupted request");
return true;
}
}
if (message.type === "result" && message.subtype !== "success" && this.isAbortError(message)) {
this.logger.debug("Suppressing abort result by content");
return true;
}
return false;
}
/**
* Claude's account of whether anything in the session is working, which is not the same as the
* main session working: Claude Code 2.1.280 stays "running" for as long as any background
* subagent is alive. So "running" never opens a turn.
*
* "idle" means nothing at all is running, so it closes an autonomous turn whatever opened it.
* It never closes a foreground turn: an idle can trail the previous turn and land just after a
* new send, and it must not end that send.
*
* Returns true when the frame was a state change, which is bookkeeping and never reaches the
* timeline.
*/
private consumeSessionStateChange(message: SDKMessage): boolean {
if (message.type !== "system" || message.subtype !== "session_state_changed") {
return false;
}
if (message.state === "idle") {
this.mainTurnInFlight = false;
this.completeAutonomousTurn();
}
return true;
}
/** Tracks mainTurnInFlight from Claude's own turn brackets (isClaudeTurnInit). */
private observeMainTurnBracket(message: SDKMessage): void {
if (message.type === "result") {
this.mainTurnInFlight = false;
return;
}
if (isClaudeTurnInit(message)) {
this.mainTurnInFlight = true;
// A new turn has begun, so the interrupted one is over. An interrupt that landed after its
// turn had already ended gets no result, and the leftover flag would swallow this turn's.
this.pendingInterruptAbort = false;
return;
}
if (readClaudeCommandLifecycle(message)?.state === "started") {
this.mainTurnInFlight = true;
}
}
/**
* Only the main session's own turn start opens an autonomous turn. Everything a background
* subagent emits (its task frames, its own background jobs' notifications, rate limit events)
* arrives outside one, and no result would ever close a turn it opened.
*/
private opensAutonomousTurn(message: SDKMessage): boolean {
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
}
private ownsQuery(query: Query): boolean {
return !this.closed && this.query === query;
}
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.consumeSessionStateChange(message)) {
return;
}
this.observeMainTurnBracket(message);
if (this.shouldSuppressStaleResult(message)) {
return;
}
const isForeground = Boolean(this.activeForegroundTurnId);
if (this.opensAutonomousTurn(message)) {
this.startAutonomousTurn();
}
if (!isForeground && !this.autonomousTurn && message.type === "result") {
return;
}
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
this.rememberTranscriptProgress(message, readTranscriptUuid(message));
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: turnId ?? undefined,
messageType: message.type,
identifiers,
rawEvent: message,
},
"provider.claude.parsed_event",
);
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) {
return;
}
if (
this.pendingInterruptAbort &&
message.type === "result" &&
events.some((event) => event.type === "turn_completed" || event.type === "turn_failed") &&
(!this.activeForegroundTurnId || !this.foregroundHasVisibleActivity)
) {
this.pendingInterruptAbort = false;
this.logger.debug("Suppressing stale Claude interrupt terminal result");
return;
}
if (
events.some((event) => event.type === "timeline" && event.item.type === "assistant_message")
) {
this.activeTurnHasAssistantText = true;
}
if (
this.activeForegroundTurnId &&
events.some(
(event) =>
event.type === "timeline" ||
event.type === "permission_requested" ||
event.type === "permission_resolved",
)
) {
this.foregroundHasVisibleActivity = true;
}
this.dispatchEvents(events);
}
private async buildPumpedMessageEvents(
message: SDKMessage,
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const query = this.query;
const messageEvents = await this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
if (
this.closed ||
this.query !== query ||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
)
return [];
const assistantTimelineEvents = readClaudeParentToolUseId(message)
? []
: this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
private async handleMissingResumedConversation(
message: SDKMessage,
activeQuery: Query,
): Promise<boolean> {
const staleResumeError = this.readMissingResumedConversationError(message);
if (!staleResumeError) {
return false;
}
this.logger.warn(
{
error: staleResumeError,
},
"Claude resumed session no longer exists; invalidating persisted session",
);
this.failActiveTurns(staleResumeError);
// Ending the input retires the process on purpose. Detach first so its exit
// is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
this.input?.end();
await this.awaitWithTimeout(
activeQuery.return?.(),
"query pump return on missing resumed conversation",
);
// Tree-kill for the same reason the restart path does: MCP children of the
// retired claude process outlive it otherwise.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
if (this.query === activeQuery) {
this.query = null;
this.input = null;
}
this.persistence = null;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = false;
this.autonomousTurn = null;
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.syncTurnState("missing resumed conversation");
return true;
}
/**
* Interrupts Claude only when a turn of its main session is running. With nothing running there
* is nothing to stop but our own queued messages, so those are withdrawn instead: an interrupt
* would reach only background helpers (and kill them on a CLI that ignores
* perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort.
*/
private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
},
"provider.claude.interrupt.no_query",
);
return;
}
if (claudeStartedTurn) {
this.pendingInterruptAbort = true;
await this.withdrawUnstartedMessages(queryToInterrupt);
} else {
// Claude can dequeue a message for its next turn before announcing it started; withdrawing
// it is then a no-op, and only an interrupt stops it. Withdraw once: a prompt sent while
// this withdrawal was in flight belongs to the next turn.
if (await this.withdrawUnstartedMessages(queryToInterrupt)) return;
this.pendingInterruptAbort = true;
}
try {
await this.awaitWithTimeout(
queryToInterrupt.interrupt(),
"interruptActiveTurn query.interrupt()",
);
} catch (error) {
this.logger.warn({ err: error }, "Failed to interrupt active turn");
}
}
/**
* Interrupt means interrupt: a message Claude never started dies with the turn instead of
* resuming it. One already dequeued cannot be recalled, and does not need to be, since the
* interrupt kills it. Returns whether Claude confirmed every message withdrawn.
*/
private async withdrawUnstartedMessages(query: Query): Promise<boolean> {
const uuids = [...this.unstartedMessageUuids];
this.unstartedMessageUuids.clear();
this.permissionClearingSteerUuids.clear();
if (uuids.length === 0) return true;
// The SDK runtime supports this, but its public Query type has not caught up. Keep the
// compatibility escape hatch inside the Claude adapter.
const cancelAsyncMessage = (
query as Query & {
cancelAsyncMessage?: (uuid: string) => Promise<boolean>;
}
).cancelAsyncMessage;
if (!cancelAsyncMessage) return false;
let withdrewAll = true;
for (const uuid of uuids) {
try {
if (!(await cancelAsyncMessage.call(query, uuid))) withdrewAll = false;
} catch (error) {
withdrewAll = false;
this.logger.warn({ err: error }, "Failed to withdraw a queued Claude message");
}
}
return withdrewAll;
}
private async translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentStreamEvent[]> {
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId);
}
const events: AgentStreamEvent[] = [];
this.appendTaskStateEvent(message, events);
// Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are
// read rather than inferred from sidechain frames. `task_started` precedes the child's first
// frame, so the descriptor exists before any timeline item lands on it.
const subagentObservations = this.taskProtocolSource.observe(message);
for (const event of foldSubagentObservations(subagentObservations)) {
events.push({ type: "provider_subagent", provider: "claude", event });
}
this.releaseFinishedSubagentToolCalls(subagentObservations);
for (const observation of subagentObservations) {
if (observation.kind !== "declared") continue;
if (!this.taskProtocolSource.needsSyntheticParentToolCard(observation.id)) continue;
const card = this.buildSubagentToolCallCard(observation);
if (card) events.push(card);
}
if (message.type !== "system") {
const sessionCapture = this.captureSessionIdFromMessage(message);
if (sessionCapture.notice) {
events.push({
type: "timeline",
provider: "claude",
item: sessionCapture.notice,
});
}
if (sessionCapture.threadStartedSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: sessionCapture.threadStartedSessionId,
});
}
}
this.forgetStartedMessage(message);
switch (message.type) {
case "system":
this.appendSystemMessageEvents(message, events);
break;
case "user":
await this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.appendSidechainResultEvents(message, events);
break;
}
case "stream_event":
await this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events);
break;
default:
break;
}
return events;
}
/**
* Once Claude has started a message, or cancelled it, there is nothing left to withdraw on
* interrupt.
*/
private forgetStartedMessage(message: unknown): void {
const lifecycle = readClaudeCommandLifecycle(message);
if (!lifecycle || lifecycle.state === "queued") return;
this.unstartedMessageUuids.delete(lifecycle.commandUuid);
this.permissionClearingSteerUuids.delete(lifecycle.commandUuid);
}
private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void {
const item = this.taskState.observe(message);
if (item) events.push({ type: "timeline", provider: "claude", item });
}
/**
* A frame from inside a subagent, routed to that child rather than the parent's transcript.
*/
private translateSidechainFrameToEvents(
message: SDKMessage,
parentToolUseId: string,
): AgentStreamEvent[] {
const canonicalSubagentId = this.taskProtocolSource.resolveSubagentId(parentToolUseId);
// Once a CLI announces its tasks it announces all of them, so a frame for one that was never
// declared is work the filter already rejected — a workflow child, ambient housekeeping, a
// grandchild announced in someone else's session. Attributing it anyway materializes exactly
// what the filter prevents: a nameless descriptor stuck running, plus a timeline no surface
// can open, both held for the session's lifetime.
if (this.taskProtocolSource.announcesTasks && !canonicalSubagentId) {
return [];
}
// The child's own frames are the only place its model appears; the task protocol does not
// announce it. Read per frame rather than snapshotting, since a provider can swap models
// mid-flight on overload or refusal fallback.
const runtimeEvents = foldSubagentObservations(
this.taskProtocolSource.observeSidechainFrame(
message,
canonicalSubagentId ?? parentToolUseId,
),
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
const routedId = canonicalSubagentId ?? parentToolUseId;
const settled = this.settledSubagentToolCalls.get(routedId);
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
return event;
});
return [...runtimeEvents, ...sidechainEvents];
}
/**
* The Task card in the parent transcript, built from the declaration.
*
* The sidechain tracker also emits this card when it first sees the child, and the two
* collapse on the shared tool-call id. Emitting it at declaration matters because a
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
*/
private buildSubagentToolCallCard(
declaration: Extract<SubagentObservation, { kind: "declared" }>,
): AgentStreamEvent | null {
// The launching sidechain already owns and renders this tool call. Mirroring it into the
// managed parent's transcript would flatten both the card and the child relationship.
if (declaration.parentSubagentId) return null;
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: declaration.id,
input: null,
output: null,
});
if (!toolCall) return null;
return {
type: "timeline",
provider: "claude",
item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) },
};
}
private appendSidechainResultEvents(message: SDKMessage, events: AgentStreamEvent[]): void {
const content = toObjectRecord(toObjectRecord(message)?.message)?.content;
if (!Array.isArray(content)) return;
for (const block of content) {
const chunk = toObjectRecord(block);
if (chunk?.type !== "tool_result" || typeof chunk.tool_use_id !== "string") continue;
events.push(
...this.sidechainTracker.finish(chunk.tool_use_id, chunk.is_error ? "failed" : "completed"),
);
}
}
private async emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): Promise<void> {
const events: AgentStreamEvent[] = [];
await this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
if (events.length === 0) {
return;
}
this.foregroundHasVisibleActivity = true;
for (const event of events) {
if (event.type === "timeline") {
const item =
event.item.type === "user_message" && clientMessageId
? { ...event.item, clientMessageId }
: event.item;
this.notifySubscribers({ ...event, item, turnId });
} else {
this.notifySubscribers(event);
}
}
}
private appendSystemMessageEvents(
message: Extract<SDKMessage, { type: "system" }>,
events: AgentStreamEvent[],
): void {
if (message.subtype === "init") {
const sessionUpdate = this.handleSystemMessage(message);
if (sessionUpdate.notice) {
events.push({
type: "timeline",
provider: "claude",
item: sessionUpdate.notice,
});
}
if (sessionUpdate.threadStartedSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: sessionUpdate.threadStartedSessionId,
});
}
return;
}
if (message.subtype === "status") {
// Claude Code reports a mode it switched to on its own, such as entering
// plan mode with the EnterPlanMode tool, through the status message.
if (
isPermissionMode(message.permissionMode) &&
this.observePermissionMode(message.permissionMode)
) {
events.push({
type: "mode_changed",
provider: "claude",
currentModeId: this.currentMode,
availableModes: this.availableModes,
});
}
const status = toObjectRecord(message)?.status;
if (status === "compacting") {
this.compacting = true;
// Claude Code repeats this status every 30 seconds until the compaction
// finishes. Each repeat used to open its own marker, and the app only ever
// resolves one of them, so the rest stayed on "Compacting..." forever.
if (!this.compactionMarkerOpen) {
this.compactionMarkerOpen = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
}
}
return;
}
if (message.subtype === "compact_boundary") {
this.compactionMarkerOpen = false;
const compactMetadata = readCompactionMetadata(message);
events.push({
type: "timeline",
item: {
type: "compaction",
status: "completed",
trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
},
provider: "claude",
});
events.push(this.contextUsage.buildCompactionUsageEvent(compactMetadata?.postTokens));
return;
}
if (message.subtype === "task_notification") {
this.appendTaskNotificationEvents(message, events);
return;
}
if (message.subtype === "background_tasks_changed") {
this.logger.debug(
{ tasks: message.tasks.map(({ task_id, task_type }) => ({ task_id, task_type })) },
"claude.background_tasks_changed",
);
return;
}
if (message.subtype === "task_progress") {
return;
}
}
private appendTaskNotificationEvents(
message: Extract<SDKMessage, { type: "system"; subtype: "task_notification" }>,
events: AgentStreamEvent[],
): void {
const taskUseId = message.tool_use_id;
const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined;
// The task protocol owns provider-subagent identity. Workflow launch results arrive before
// their terminal notification and clear toolUseCache, so the cache is only a fallback for
// older Claude streams which do not announce tasks.
if (
this.taskProtocolSource.isDeclaredTask(message.task_id) ||
isClaudeSubagentToolName(cachedTool?.name)
) {
return;
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
const ownerSubagentId = this.taskProtocolSource.resolveTaskOwner(message.task_id, taskUseId);
if (taskNotificationItem && ownerSubagentId) {
events.push({
type: "provider_subagent",
provider: "claude",
event: { type: "timeline", id: ownerSubagentId, item: taskNotificationItem },
});
return;
}
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
}
}
private async appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
): Promise<void> {
if (isSyntheticUserEntry(message)) {
return;
}
if (this.compacting) {
this.compacting = false;
return;
}
const messageId =
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
if (messageId && this.emittedUserMessageIds.has(messageId)) {
return;
}
this.rememberUserMessageId(messageId);
this.rememberEmittedUserMessageId(messageId);
const content = message.message?.content;
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId,
});
if (taskNotificationItem) {
this.appendUserTaskNotificationEvent(taskNotificationItem, events);
return;
}
if (typeof content === "string" && content.length > 0) {
if (!isClaudeTranscriptNoiseText(content)) {
events.push({
type: "timeline",
item: {
type: "user_message",
text: content,
...(messageId ? { messageId } : {}),
},
provider: "claude",
});
}
return;
}
if (Array.isArray(content)) {
await this.appendUserContentArrayEvents(content, messageId, events);
}
}
private appendUserTaskNotificationEvent(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
events: AgentStreamEvent[],
): void {
const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined;
const toolUseId =
typeof item.metadata?.toolUseId === "string" ? item.metadata.toolUseId : undefined;
if (taskId && this.taskProtocolSource.isDeclaredTask(taskId)) return;
const ownerSubagentId = taskId
? this.taskProtocolSource.resolveTaskOwner(taskId, toolUseId)
: undefined;
if (ownerSubagentId) {
events.push({
type: "provider_subagent",
provider: "claude",
event: { type: "timeline", id: ownerSubagentId, item },
});
return;
}
events.push({ type: "timeline", item, provider: "claude" });
}
private async appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
events: AgentStreamEvent[],
): Promise<void> {
const timelineItems = await this.mapBlocksToTimeline(content, {
textMessageType: "user_message",
});
for (const item of timelineItems) {
if (item.type === "user_message" && messageId && !item.messageId) {
events.push({
type: "timeline",
item: { ...item, messageId },
provider: "claude",
});
continue;
}
events.push({ type: "timeline", item, provider: "claude" });
}
}
private async appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): Promise<void> {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = await this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
}
private appendResultEvents(
message: Extract<SDKMessage, { type: "result" }>,
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
// is 0 and the user-visible text is carried in `result`. Surface it only
// when the turn has not already emitted assistant text so zero-token
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: resultText,
messageId: message.uuid,
},
});
}
events.push({ type: "turn_completed", provider: "claude", usage });
return;
}
const errorMessage =
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
): AgentTimelineItem {
return {
type: "assistant_message",
text: `Claude switched to a new session: ${oldSessionId} -> ${newSessionId}`,
};
}
private captureSessionIdFromMessage(message: SDKMessage): {
threadStartedSessionId: string | null;
notice: AgentTimelineItem | null;
} {
const msgRecord = toObjectRecord(message) ?? {};
const sessionId = extractSessionIdRaw({
session_id: msgRecord.session_id,
sessionId: msgRecord.sessionId,
session: isObjectRecord(msgRecord.session) ? { id: msgRecord.session.id } : null,
}).trim();
if (!sessionId) {
return { threadStartedSessionId: null, notice: null };
}
if (this.claudeSessionId === null) {
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
return { threadStartedSessionId: sessionId, notice: null };
}
if (this.claudeSessionId === sessionId) {
this.pendingFreshSessionId = null;
return { threadStartedSessionId: null, notice: null };
}
const oldSessionId = this.claudeSessionId;
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
// with a new session). Accept the new ID and continue — the turn should
// not be failed just because the underlying subprocess cycled.
this.logger.warn(
{ existingSessionId: this.claudeSessionId, newSessionId: sessionId },
"Claude session ID changed in message; accepting new session",
);
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
return {
threadStartedSessionId: sessionId,
notice: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
};
}
private handleSystemMessage(message: SDKSystemMessage): {
threadStartedSessionId: string | null;
notice: AgentTimelineItem | null;
} {
if (message.subtype !== "init") {
return { threadStartedSessionId: null, notice: null };
}
const msgRecord = toObjectRecord(message) ?? {};
const newSessionId = extractSessionIdRaw({
session_id: msgRecord.session_id,
sessionId: msgRecord.sessionId,
session: isObjectRecord(msgRecord.session) ? { id: msgRecord.session.id } : null,
}).trim();
if (!newSessionId) {
return { threadStartedSessionId: null, notice: null };
}
const existingSessionId = this.claudeSessionId;
let threadStartedSessionId: string | null = null;
let notice: AgentTimelineItem | null = null;
if (existingSessionId === null) {
this.claudeSessionId = newSessionId;
this.pendingFreshSessionId = null;
threadStartedSessionId = newSessionId;
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
} else if (existingSessionId === newSessionId) {
this.pendingFreshSessionId = null;
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
} else {
// Session ID changed in an init message (e.g. a hook restarted Claude
// with a new session mid-turn). Accept the new ID and continue.
this.logger.warn(
{ existingSessionId, newSessionId },
"Claude session ID changed in init message; accepting new session",
);
this.claudeSessionId = newSessionId;
this.pendingFreshSessionId = null;
threadStartedSessionId = newSessionId;
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
}
this.availableModes = DEFAULT_MODES;
this.observePermissionMode(message.permissionMode);
this.persistence = null;
if (message.model) {
const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model);
// Seed usage before the first result, including settings-resolved aliases.
// A repeated init for the same model must retain the SDK's reported limit.
if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) {
this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens);
}
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
this.logger.debug(
{ runtimeModel: message.model, normalizedRuntimeModel },
"Captured runtime model from SDK init",
);
if (normalizedRuntimeModel) {
this.lastOptionsModel = normalizedRuntimeModel;
} else if (!this.lastOptionsModel) {
this.lastOptionsModel = this.config.model ?? null;
}
this.lastRuntimeModel = message.model;
this.cachedRuntimeInfo = null;
}
return { threadStartedSessionId, notice };
}
/** Records a mode Claude Code reports, returning whether it differs from the current one. */
private observePermissionMode(mode: PermissionMode): boolean {
const changed = this.currentMode !== mode;
this.currentMode = mode;
if (mode !== "plan") {
this.planResumeMode = mode;
}
return changed;
}
private readMissingResumedConversationError(message: SDKMessage): string | null {
if (message.type !== "result" || message.subtype !== "error_during_execution") {
return null;
}
if (!this.claudeSessionId) {
return null;
}
const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
for (const entry of errors) {
if (typeof entry !== "string") {
continue;
}
const match = entry.match(/^No conversation found with session ID:\s*(.+)$/);
if (!match) {
continue;
}
if (match[1]?.trim() === this.claudeSessionId) {
return entry.trim();
}
}
return null;
}
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model);
}
private handlePermissionRequest: CanUseTool = async (
toolName,
input,
options,
): Promise<PermissionResult> => {
const requestId = `permission-${randomUUID()}`;
const kind = resolvePermissionKind(toolName, input);
const requestInput = normalizeClaudeAskUserQuestionRequestInput(toolName, input);
const metadata: AgentMetadata = {};
if (options.toolUseID) {
metadata.toolUseId = options.toolUseID;
}
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan;
}
const toolDetail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
})?.detail
: undefined;
const request: AgentPermissionRequest = {
id: requestId,
provider: "claude",
name: toolName,
kind,
...buildClaudeQuestionPermissionSummary(toolName, input),
input: requestInput,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({
...suggestion,
})),
actions: kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
if (this.permissionClearingSteerUuids.size > 0) {
return this.resolveDeniedPermission(request, {
behavior: "deny",
message: STEER_SUPERSEDED_PERMISSION_MESSAGE,
});
}
return await new Promise<PermissionResult>((resolve, reject) => {
const cleanupFns: Array<() => void> = [];
const cleanup = () => {
while (cleanupFns.length) {
const fn = cleanupFns.pop();
try {
fn?.();
} catch {
// ignore cleanup errors
}
}
};
const abortHandler = () => {
this.pendingPermissions.delete(requestId);
cleanup();
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
});
reject(new Error("Permission request aborted"));
};
if (options?.signal) {
if (options.signal.aborted) {
abortHandler();
return;
}
options.signal.addEventListener("abort", abortHandler, { once: true });
cleanupFns.push(() => options.signal?.removeEventListener("abort", abortHandler));
}
this.pendingPermissions.set(requestId, {
request,
resolve,
reject,
cleanup,
});
});
};
private enqueueTimeline(item: AgentTimelineItem) {
this.pushEvent({ type: "timeline", item, provider: "claude" });
}
private flushPendingToolCalls() {
for (const [id, entry] of this.toolUseCache) {
if (entry.started) {
this.pushToolCall(
mapClaudeCanceledToolCall({
name: entry.name,
callId: id,
input: entry.input ?? null,
output: null,
}),
);
}
}
this.toolUseCache.clear();
this.sidechainTracker.clear();
// The task protocol's routing table is session-scoped, so it is deliberately NOT reset here:
// the turn ended, the session did not. Wiping it would strand every task id the session still
// holds — a backgrounded child that settles after the interrupt would find no descriptor to
// land on, and ownership would flip back to the legacy tracker mid-session.
for (const event of foldSubagentObservations(
this.taskProtocolSource.cancelRunningForegroundTasks(),
)) {
this.pushEvent({ type: "provider_subagent", provider: "claude", event });
}
}
private pushToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
target?: AgentTimelineItem[],
) {
if (!item) {
return;
}
if (target) {
target.push(item);
return;
}
this.enqueueTimeline(item);
}
private pushEvent(event: AgentStreamEvent) {
this.notifySubscribers(event);
}
/**
* Effort is reachable only through hooks.
*
* It appears nowhere on the message stream — verified by scanning every message type at depth
* — and the level Paseo requests is not necessarily the level that runs, because a model that
* does not support it is silently downgraded. A hook firing inside a subagent reports the
* active post-downgrade level alongside the subagent's `agent_id`.
*
* These are observation-only: they record what they see and always return an empty result, so
* they can never alter tool execution or turn control.
*/
private buildSubagentEffortHooks(): NonNullable<ClaudeOptions["hooks"]> {
const observe = async (input: unknown): Promise<Record<string, never>> => {
try {
for (const event of foldSubagentObservations(
this.taskProtocolSource.observeHook(input as ClaudeHookObservationInput),
)) {
this.notifySubscribers({ type: "provider_subagent", provider: "claude", event });
}
} catch (error) {
this.logger.debug({ err: error }, "Failed to read subagent effort from hook");
}
return {};
};
// SubagentStart carries no effort (documented as absent for lifecycle hooks), so the value
// lands on the child's first tool use. SubagentStop covers a child that used no tools.
return {
PreToolUse: [{ hooks: [observe] }],
PostToolUse: [{ hooks: [observe] }],
SubagentStop: [{ hooks: [observe] }],
};
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId =
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.claude.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
} catch (error) {
this.logger.warn({ err: error }, "Subscriber callback threw");
}
}
}
private normalizePermissionUpdates(
updates?: AgentPermissionUpdate[],
): PermissionUpdate[] | undefined {
if (!updates || updates.length === 0) {
return undefined;
}
const normalized = updates.filter(isPermissionUpdate);
return normalized.length > 0 ? normalized : undefined;
}
private rejectAllPendingPermissions(error: Error) {
for (const [id, pending] of this.pendingPermissions) {
pending.cleanup?.();
pending.reject(error);
this.pendingPermissions.delete(id);
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId: id,
resolution: { behavior: "deny", message: error.message },
});
}
}
ensureHistoryReady(): Promise<void> {
if (!this.historyReady) {
this.historyReady = this.historySessionId
? this.loadPersistedHistory(this.historySessionId)
: Promise.resolve();
}
return this.historyReady;
}
private async loadPersistedHistory(sessionId: string): Promise<void> {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
{ err: error, sessionId, historyPath },
"Failed to load Claude history from transcript",
);
} finally {
this.replayingHistory = false;
}
}
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): Promise<void> {
const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) {
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
if (timeline.length > 0) {
this.persistedHistory = [...this.persistedHistory, ...timeline];
this.historyPending = true;
}
}
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagents = [];
for (const subagent of history.subagents) {
subagents.push({
...subagent,
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...(await observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
];
this.taskProtocolSource.restore(subagentReplay.restored);
const restoredProviderSubagentIds = new Set(
observations
.filter((observation) => observation.kind === "declared")
.map((observation) => observation.id),
);
const replay = {
restoredIds: restoredProviderSubagentIds,
toolOwners: subagentReplay.toolOwners,
subagentToolCalls: parentFacts.toolCalls,
};
if (observations.length === 0) return replay;
for (const event of foldSubagentObservations(observations)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
event,
});
}
this.historyPending = true;
return replay;
}
private async ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership,
): Promise<void> {
if (entry.isSidechain) {
return;
}
const notificationToolUseId = readTaskNotificationToolUseIdFromHistoryRecord(entry);
if (notificationToolUseId && replay.restoredIds.has(notificationToolUseId)) {
return;
}
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const notificationOwner = notificationToolUseId
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of await this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
event: {
type: "timeline",
id: notificationOwner,
item,
timestamp: historyTimestamp ?? undefined,
},
});
}
return;
}
const taskSnapshot = this.taskState.observe(entry);
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...(await this.convertHistoryEntry(entry)).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
const isVisibleUserEntry =
entry.type === "user" &&
typeof entry.uuid === "string" &&
!isSyntheticHistoryUserEntry(entry) &&
!isToolResultUserEntry(entry);
if (isVisibleUserEntry && typeof entry.uuid === "string") {
this.rememberUserMessageId(entry.uuid);
this.rememberRewindUserAnchor(entry.uuid);
}
if (entry.type === "assistant" && typeof entry.uuid === "string") {
this.rememberRewindAssistantAnchor(entry.uuid);
}
if (items.length > 0) {
for (const item of items) {
timeline.push({
item,
timestamp: historyTimestamp ?? undefined,
});
}
}
}
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPath({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
}
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
return (
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
).map(limitAgentTimelineItemContent);
}
// Maps Claude content blocks into AgentTimelineItems.
//
// textMessageType controls what type text blocks emit:
// - "assistant_message" (default): one item per text block (streaming granularity)
// - "user_message": coalesces all text blocks into a single user_message
// (matches extractUserMessageText semantics: trim each block, join with "\n\n")
//
// suppressAssistantText only applies when textMessageType is "assistant_message" — user text
// must never be suppressed since the TimelineAssembler only handles assistant text.
//
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items.
private async mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
const suppressReasoning = options?.suppressReasoning ?? false;
if (typeof content === "string") {
if (
!content ||
content === INTERRUPT_TOOL_USE_PLACEHOLDER ||
isClaudeTranscriptNoiseText(content)
) {
return [];
}
if (suppressText) {
return [];
}
return [{ type: textMessageType, text: content }];
}
const items: AgentTimelineItem[] = [];
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
const userTextParts: string[] = [];
for (const block of content) {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
await this.mapBlockToTimeline(block, {
items,
userTextParts,
textMessageType,
suppressText,
suppressReasoning,
});
}
if (textMessageType === "user_message" && userTextParts.length > 0) {
items.unshift({
type: "user_message",
text: userTextParts.join("\n\n"),
});
}
return items;
}
private appendTextBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
userTextParts: string[];
textMessageType: "assistant_message" | "user_message";
suppressText: boolean;
},
): void {
const { items, userTextParts, textMessageType, suppressText } = context;
const text = typeof block.text === "string" ? block.text : "";
if (!text || text === INTERRUPT_TOOL_USE_PLACEHOLDER || isClaudeTranscriptNoiseText(text)) {
return;
}
if (textMessageType === "user_message") {
const trimmed = text.trim();
if (trimmed) {
userTextParts.push(trimmed);
}
return;
}
if (!suppressText) {
items.push({ type: "assistant_message", text });
}
}
private async mapBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
userTextParts: string[];
textMessageType: "assistant_message" | "user_message";
suppressText: boolean;
suppressReasoning: boolean;
},
): Promise<void> {
switch (block.type) {
case "text":
case "text_delta":
this.appendTextBlockToTimeline(block, context);
break;
case "thinking":
case "thinking_delta":
if (typeof block.thinking === "string" && block.thinking && !context.suppressReasoning) {
context.items.push({ type: "reasoning", text: block.thinking });
}
break;
case "tool_use":
case "server_tool_use":
case "mcp_tool_use":
this.handleToolUseStart(block, context.items);
break;
case "tool_result":
case "mcp_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
await this.handleToolResult(block, context.items);
break;
default:
break;
}
}
// A subagent's handback call is its final message, and the call's result is only an
// acknowledgement, so neither becomes a tool call.
private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean {
if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) {
this.subagentHandbackIds.delete(block.tool_use_id);
return true;
}
const handback = readClaudeSubagentHandback(block);
if (!handback) {
return false;
}
this.subagentHandbackIds.add(handback.callId);
if (handback.report) {
items.push({ type: "assistant_message", text: handback.report });
}
return true;
}
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry = this.upsertToolUseEntry(block);
if (!entry) {
return;
}
if (entry.started) {
return;
}
entry.started = true;
this.toolUseCache.set(entry.id, entry);
this.pushToolCall(
mapClaudeRunningToolCall({
name: entry.name,
callId: entry.id,
input: entry.input ?? this.normalizeToolInput(block.input) ?? null,
output: null,
}),
items,
);
}
private async handleToolResult(
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
const entry =
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
const toolName = entry?.name ?? blockToolName ?? "tool";
const callId =
typeof block.tool_use_id === "string" && block.tool_use_id.length > 0
? block.tool_use_id
: (entry?.id ?? null);
// Pull image blocks out of the result so base64 never reaches the tool output, and render each
// one as an assistant_message markdown image after the tool_call (matching how Codex emits).
const { images, text } = splitClaudeToolResultImages(block.content);
const output = this.buildToolOutput(text, block, entry);
const settledToolCall = block.is_error
? mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: { ...block, content: text },
})
: mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
});
this.pushToolCall(settledToolCall, items);
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) {
items.push(imageItem);
}
}
if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id);
}
}
private rememberSettledSubagentToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
entry: ToolUseCacheEntry | undefined,
): void {
const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined;
if (!item || !subagentId) return;
this.settledSubagentToolCalls.set(subagentId, {
item,
input: entry?.input ?? null,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null,
});
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
this.sidechainTracker.delete(observation.id);
}
}
}
private buildToolOutput(
content: unknown,
block: ClaudeContentChunk,
entry: ToolUseCacheEntry | undefined,
): AgentMetadata | undefined {
if (block.is_error) {
return undefined;
}
const blockServer = typeof block.server === "string" ? block.server : undefined;
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
const server = entry?.server ?? blockServer ?? "tool";
const tool = entry?.name ?? blockToolName ?? "tool";
const coercedContent = coerceToolResultContentToString(content);
const input = entry?.input;
// Build structured result based on tool type
const structured = this.buildStructuredToolResult(server, tool, coercedContent, input);
if (structured) {
return structured;
}
// Fallback format - try to parse JSON first
const result: AgentMetadata = {};
if (coercedContent.length > 0) {
try {
// If content is a JSON string, parse it
result.output = JSON.parse(coercedContent);
} catch {
// If not JSON, return unchanged (no extra wrapping)
result.output = coercedContent;
}
}
// Preserve file changes tracked during tool execution
if (entry?.files?.length) {
result.files = entry.files;
}
return Object.keys(result).length > 0 ? result : undefined;
}
private isCommandExecutionTool(
normalizedServer: string,
normalizedTool: string,
input: AgentMetadata | null | undefined,
): boolean {
if (
normalizedServer.includes("bash") ||
normalizedServer.includes("shell") ||
normalizedServer.includes("command")
) {
return true;
}
if (
normalizedTool.includes("bash") ||
normalizedTool.includes("shell") ||
normalizedTool.includes("command")
) {
return true;
}
return Boolean(input && (typeof input.command === "string" || Array.isArray(input.command)));
}
private static isFileWriteTool(normalizedTool: string): boolean {
return (
normalizedTool.includes("write") ||
normalizedTool === "write_file" ||
normalizedTool === "create_file"
);
}
private static isFileEditTool(normalizedTool: string): boolean {
return (
normalizedTool.includes("edit") ||
normalizedTool.includes("patch") ||
normalizedTool === "apply_patch" ||
normalizedTool === "apply_diff"
);
}
private static isFileReadTool(normalizedTool: string): boolean {
return (
normalizedTool.includes("read") ||
normalizedTool === "read_file" ||
normalizedTool === "view_file"
);
}
private buildStructuredToolResult(
server: string,
tool: string,
output: string,
input?: AgentMetadata | null,
): AgentMetadata | undefined {
const normalizedServer = server.toLowerCase();
const normalizedTool = tool.toLowerCase();
if (this.isCommandExecutionTool(normalizedServer, normalizedTool, input)) {
const command = this.extractCommandText(input ?? {}) ?? "command";
return {
type: "command",
command,
output,
cwd: typeof input?.cwd === "string" ? input.cwd : undefined,
};
}
if (
ClaudeAgentSession.isFileWriteTool(normalizedTool) &&
input &&
typeof input.file_path === "string"
) {
return {
type: "file_write",
filePath: input.file_path,
oldContent: "",
newContent: typeof input.content === "string" ? input.content : output,
};
}
if (
ClaudeAgentSession.isFileEditTool(normalizedTool) &&
input &&
typeof input.file_path === "string"
) {
// Support both old_str/new_str and old_string/new_string parameter names
const oldContent = firstStringField(input, "old_str", "old_string");
const newContent = firstStringField(input, "new_str", "new_string");
const diff = firstStringField(input, "patch", "diff");
return {
type: "file_edit",
filePath: input.file_path,
diff,
oldContent,
newContent,
};
}
if (
ClaudeAgentSession.isFileReadTool(normalizedTool) &&
input &&
typeof input.file_path === "string"
) {
return {
type: "file_read",
filePath: input.file_path,
content: output,
};
}
return undefined;
}
private updatePartialEventToolState(event: SDKPartialAssistantMessage["event"]): boolean {
if (event.type === "content_block_start") {
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
if (
block?.type === "tool_use" &&
typeof event.index === "number" &&
typeof block.id === "string"
) {
this.toolUseIndexToId.set(event.index, block.id);
this.toolUseInputBuffers.delete(block.id);
}
return false;
}
if (event.type === "content_block_delta") {
const delta = isClaudeContentChunk(event.delta) ? event.delta : null;
if (delta?.type === "input_json_delta") {
const partialJson = typeof delta.partial_json === "string" ? delta.partial_json : undefined;
this.handleToolInputDelta(event.index, partialJson);
return true;
}
return false;
}
if (event.type === "content_block_stop" && typeof event.index === "number") {
const toolId = this.toolUseIndexToId.get(event.index);
if (toolId) {
this.toolUseIndexToId.delete(event.index);
this.toolUseInputBuffers.delete(toolId);
}
}
return false;
}
private async mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
if (this.updatePartialEventToolState(event)) {
return [];
}
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? await this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? await this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
default:
return [];
}
}
private upsertToolUseEntry(block: ClaudeContentChunk): ToolUseCacheEntry | null {
const id = typeof block.id === "string" ? block.id : undefined;
if (!id) {
return null;
}
const existing = this.toolUseCache.get(id) ?? createDefaultToolUseCacheEntry(id, block);
if (typeof block.name === "string" && block.name.length > 0) {
existing.name = block.name;
}
if (typeof block.server === "string" && block.server.length > 0) {
existing.server = block.server;
} else if (!existing.server) {
existing.server = existing.name;
}
if (
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) {
const input = this.normalizeToolInput(block.input);
if (input) {
this.applyToolInput(existing, input);
}
}
this.toolUseCache.set(id, existing);
return existing;
}
private handleToolInputDelta(index: number | undefined, partialJson: string | undefined): void {
if (typeof index !== "number" || typeof partialJson !== "string") {
return;
}
const toolId = this.toolUseIndexToId.get(index);
if (!toolId) {
return;
}
const buffer = (this.toolUseInputBuffers.get(toolId) ?? "") + partialJson;
this.toolUseInputBuffers.set(toolId, buffer);
const entry = this.toolUseCache.get(toolId);
const parsed = parsePartialJsonObject(buffer);
if (!entry || !parsed) {
return;
}
const normalized = this.normalizeToolInput(parsed.value);
if (!normalized) {
return;
}
if (!parsed.complete && Object.keys(normalized).length === 0) {
return;
}
if (this.areToolInputsEqual(entry.input ?? undefined, normalized)) {
return;
}
this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry);
this.pushToolCall(
mapClaudeRunningToolCall({
name: entry.name,
callId: toolId,
input: normalized,
output: null,
}),
);
}
private normalizeToolInput(input: unknown): AgentMetadata | null {
if (!isMetadata(input)) {
return null;
}
return input;
}
private areToolInputsEqual(left: AgentMetadata | undefined, right: AgentMetadata): boolean {
if (!left) {
return false;
}
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
return rightKeys.every((key) => left[key] === right[key]);
}
private applyToolInput(entry: ToolUseCacheEntry, input: AgentMetadata): void {
entry.input = input;
if (this.isCommandTool(entry.name, input)) {
entry.classification = "command";
entry.commandText = this.extractCommandText(input) ?? entry.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
entry.classification = "file_change";
entry.files = files;
}
}
}
private isCommandTool(name: string, input: AgentMetadata): boolean {
const normalized = name.toLowerCase();
if (
normalized.includes("bash") ||
normalized.includes("shell") ||
normalized.includes("terminal") ||
normalized.includes("command")
) {
return true;
}
if (typeof input.command === "string" || Array.isArray(input.command)) {
return true;
}
return false;
}
private extractCommandText(input: AgentMetadata): string | undefined {
const command = input.command;
if (typeof command === "string" && command.length > 0) {
return command;
}
if (Array.isArray(command)) {
const tokens = command.filter((value): value is string => typeof value === "string");
if (tokens.length > 0) {
return tokens.join(" ");
}
}
if (typeof input.description === "string" && input.description.length > 0) {
return input.description;
}
return undefined;
}
private extractFileChanges(input: AgentMetadata): { path: string; kind: string }[] | undefined {
if (typeof input.file_path === "string" && input.file_path.length > 0) {
const relative = this.relativizePath(input.file_path);
if (relative) {
return [{ path: relative, kind: this.detectFileKind(input.file_path) }];
}
}
if (typeof input.patch === "string" && input.patch.length > 0) {
const files = this.parsePatchFileList(input.patch);
if (files.length > 0) {
return files.map((entry) => ({
path: this.relativizePath(entry.path) ?? entry.path,
kind: entry.kind,
}));
}
}
if (Array.isArray(input.files)) {
const files: { path: string; kind: string }[] = [];
for (const value of input.files) {
if (typeof value === "string" && value.length > 0) {
files.push({
path: this.relativizePath(value) ?? value,
kind: this.detectFileKind(value),
});
}
}
if (files.length > 0) {
return files;
}
}
return undefined;
}
private detectFileKind(filePath: string): string {
// A historical target may be on an unreachable mount; its current state says nothing
// about whether the past operation created or updated it.
if (this.replayingHistory) {
return "update";
}
try {
return fs.existsSync(filePath) ? "update" : "add";
} catch {
return "update";
}
}
private relativizePath(target?: string): string | undefined {
if (!target) {
return undefined;
}
const cwd = this.config.cwd;
if (cwd && target.startsWith(cwd)) {
const relative = path.relative(cwd, target);
return relative.length > 0 ? relative : path.basename(target);
}
return target;
}
private parsePatchFileList(patch: string): { path: string; kind: string }[] {
const files: { path: string; kind: string }[] = [];
const seen = new Set<string>();
for (const line of patch.split(/\r?\n/)) {
const trimmed = line.trim();
let kind: string | null = null;
let parsedPath: string | null = null;
if (trimmed.startsWith("*** Add File:")) {
kind = "add";
parsedPath = trimmed.replace("*** Add File:", "").trim();
} else if (trimmed.startsWith("*** Delete File:")) {
kind = "delete";
parsedPath = trimmed.replace("*** Delete File:", "").trim();
} else if (trimmed.startsWith("*** Update File:")) {
kind = "update";
parsedPath = trimmed.replace("*** Update File:", "").trim();
}
if (kind && parsedPath && !seen.has(`${kind}:${parsedPath}`)) {
seen.add(`${kind}:${parsedPath}`);
files.push({ path: parsedPath, kind });
}
}
return files;
}
}
function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
if (!block || typeof block !== "object") {
return false;
}
const type = typeof block.type === "string" ? block.type.toLowerCase() : "";
return type.includes("tool");
}
function readCompactionMetadata(
source: unknown,
): { trigger?: string; preTokens?: number; postTokens?: number } | null {
const sourceRecord = toObjectRecord(source);
if (!sourceRecord) {
return null;
}
const candidates = [
sourceRecord.compact_metadata,
sourceRecord.compactMetadata,
sourceRecord.compactionMetadata,
];
for (const candidate of candidates) {
const metadata = toObjectRecord(candidate);
if (!metadata) {
continue;
}
const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined;
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined;
const postTokensRaw = metadata.postTokens ?? metadata.post_tokens;
const postTokens = typeof postTokensRaw === "number" ? postTokensRaw : undefined;
return { trigger, preTokens, postTokens };
}
return null;
}
function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null {
if (Array.isArray(content)) {
const blocks = content.filter((entry) => isClaudeContentChunk(entry));
return blocks.length > 0 ? blocks : null;
}
if (isClaudeContentChunk(content)) {
return [content];
}
return null;
}
/** The parent's card for a subagent, labeled with its type and task. */
function buildClaudeSubagentCardDetail(
facts: ClaudeSubagentCardFacts,
): Extract<ToolCallDetail, { type: "sub_agent" }> {
return {
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
}
/**
* Live, a Task call's card is built from the task protocol's declaration. Replay has no task
* protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts.
*/
function labelReplayedSubagentCard(
item: AgentTimelineItem,
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>,
): AgentTimelineItem {
if (item.type !== "tool_call") return item;
const facts = subagentToolCalls.get(item.callId);
if (!facts) return item;
return { ...item, detail: buildClaudeSubagentCardDetail(facts) };
}
/**
* Collect what the parent transcript knows about its Task calls, in the shape the shared replay
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes: Map<string, { status: ProviderSubagentStatus }> = new Map();
for await (const entry of entries) {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
if (notification?.toolUseId && status) {
outcomes.set(notification.toolUseId, { status });
}
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
const block = toObjectRecord(value);
const declaration = readClaudeReplayTaskDeclaration(entry.type, block);
if (declaration) toolCalls.set(declaration.id, declaration.identity);
if (
entry.type !== "user" ||
block?.type !== "tool_result" ||
typeof block.tool_use_id !== "string"
)
continue;
if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched")
outcomes.set(block.tool_use_id, {
status: block.is_error === true ? "failed" : "completed",
});
const agentId = readLegacyClaudeSubagentId(block.content);
if (agentId)
linksByAgentId.set(agentId, {
toolCallId: block.tool_use_id,
});
}
}
return {
toolCalls,
linksByAgentId,
outcomesByToolCallId: new Map([...outcomes].filter(([id]) => toolCalls.has(id))),
};
}
function readClaudeReplayTaskDeclaration(
entryType: unknown,
block: Record<string, unknown> | undefined,
): {
id: string;
identity: { title?: string; description?: string };
} | null {
if (
entryType !== "assistant" ||
block?.type !== "tool_use" ||
(block.name !== "Task" && block.name !== "Agent") ||
typeof block.id !== "string"
)
return null;
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
return {
id: block.id,
identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) },
};
}
function readLegacyClaudeSubagentId(content: unknown): string | undefined {
return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1];
}
interface ClaudeHistoryEntry {
type?: unknown;
subtype?: unknown;
isCompactSummary?: unknown;
isSidechain?: unknown;
agentId?: unknown;
timestamp?: unknown;
uuid?: unknown;
message?: { content?: unknown; [key: string]: unknown };
[key: string]: unknown;
}
async function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) {
return items;
}
for (const item of items) {
if (item.type === "assistant_message" && !item.messageId) {
item.messageId = assistantMessageId;
}
}
return items;
}
function convertClaudeHistoryEntryPreamble(
entry: ClaudeHistoryEntry,
): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } {
if (entry.type === "system" && entry.subtype === "compact_boundary") {
const compactMetadata = readCompactionMetadata(entry);
return {
shortCircuit: [
{
type: "compaction",
status: "completed",
trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
},
],
};
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry);
if (taskNotificationItem) {
return { shortCircuit: [taskNotificationItem] };
}
if (entry.isCompactSummary) {
return { shortCircuit: [] };
}
if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) {
return { shortCircuit: [] };
}
const steeredPrompt = readSteeredPromptHistoryItem(entry);
if (steeredPrompt) {
return { shortCircuit: [steeredPrompt] };
}
// Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each
// API request as `api-request-blob` records whose message replays the system context.
if (entry.type !== "user" && entry.type !== "assistant") {
return { shortCircuit: [] };
}
const message = entry?.message;
if (!message || !("content" in message)) {
return { shortCircuit: [] };
}
const content = message.content;
if (
(entry.type === "user" || entry.type === "assistant") &&
isClaudeTranscriptNoiseContent(content)
) {
return { shortCircuit: [] };
}
return { proceed: { content } };
}
function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
/**
* A message steered into a running turn. Claude Code gives it no user entry: it records a
* queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is
* also what a rewind to it resolves. The other modes carry task notifications, not user messages.
*/
function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null {
if (entry.type !== "attachment") return null;
const attachment = toObjectRecord(entry.attachment);
if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null;
const text = extractUserMessageText(attachment.prompt);
if (!text) return null;
const messageId = readTrimmedString(attachment.source_uuid);
return { type: "user_message", text, ...(messageId ? { messageId } : {}) };
}
export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) {
return preamble.shortCircuit;
}
const { content } = preamble.proceed;
const normalizedBlocks = normalizeHistoryBlocks(content);
const contentValue = typeof content === "string" ? content : normalizedBlocks;
const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false;
const userMessageId =
entry.type === "user" && typeof entry.uuid === "string" && entry.uuid.length > 0
? entry.uuid
: null;
if (entry.type === "user") {
const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId: userMessageId,
notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined,
});
if (userTaskNotificationItem) {
return [userTaskNotificationItem];
}
}
const timeline: AgentTimelineItem[] = [];
if (entry.type === "user") {
const text = extractUserMessageText(content);
if (text) {
timeline.push({
type: "user_message",
text,
...(userMessageId ? { messageId: userMessageId } : {}),
});
}
}
if (hasToolBlock && normalizedBlocks) {
const mapped = await mapBlocks(normalizedBlocks);
if (entry.type === "user") {
// tool_result handling (handleToolResult) emits image markdown as an assistant_message
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path
// and must stay suppressed, so keep tool_calls plus only the image assistant_messages.
const toolItems = mapped.filter(
(item) => item.type === "tool_call" || isProviderImageMessage(item),
);
return timeline.length ? [...timeline, ...toolItems] : toolItems;
}
return mapped;
}
if (entry.type === "assistant" && contentValue) {
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
}
function createAsyncMessageInput<T>(): AsyncMessageInput<T> {
const queue: T[] = [];
const resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
let closed = false;
return {
push(item: T) {
if (closed) {
return;
}
const resolve = resolvers.shift();
if (resolve) {
resolve({ value: item, done: false });
return;
}
queue.push(item);
},
end() {
closed = true;
while (resolvers.length > 0) {
const resolve = resolvers.shift();
resolve?.({ value: undefined, done: true });
}
},
iterable: {
[Symbol.asyncIterator](): AsyncIterator<T, void> {
return {
next: (): Promise<IteratorResult<T, void>> => {
if (queue.length > 0) {
const value = queue.shift();
if (value !== undefined) {
return Promise.resolve({ value, done: false });
}
}
if (closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise<IteratorResult<T, void>>((resolve) => {
resolvers.push(resolve);
});
},
};
},
},
};
}
interface ClaudeSessionCandidate {
path: string;
mtime: Date;
cacheKey: string;
}
function claudeSessionCacheKey(filePath: string, stats: fs.Stats): string {
return `${filePath}\0${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`;
}
function isMissingClaudeImportFile(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}
function isExpectedClaudeImportFileError(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) return false;
return (
error.code === "ENOENT" ||
error.code === "ENOTDIR" ||
error.code === "EACCES" ||
error.code === "EPERM"
);
}
async function pathExists(target: string): Promise<boolean> {
try {
await fsPromises.access(target);
return true;
} catch (error) {
if (!isMissingClaudeImportFile(error)) throw error;
return false;
}
}
interface ClaudeSessionDiscoveryOptions {
rootIsProjectDir: boolean;
logger: Logger;
}
async function collectRecentClaudeSessions(
root: string,
limit: number,
options: ClaudeSessionDiscoveryOptions,
): Promise<ClaudeSessionCandidate[]> {
const permissionErrors: Error[] = [];
function recordDiscoveryFailure(error: unknown, targetPath: string): void {
if (isMissingClaudeImportFile(error)) return;
if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error;
permissionErrors.push(error);
options.logger.warn(
{ err: error, path: targetPath },
"Skipping Claude session discovery path with denied access",
);
}
let rootEntries: string[];
try {
rootEntries = await fsPromises.readdir(root);
} catch (error) {
if (!isMissingClaudeImportFile(error)) throw error;
return [];
}
const fileEntries = options.rootIsProjectDir
? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file))
: (
await Promise.all(
rootEntries.map(async (dirName) => {
const projectPath = path.join(root, dirName);
try {
const stats = await fsPromises.stat(projectPath);
if (!stats.isDirectory()) return [] as string[];
const files = await fsPromises.readdir(projectPath);
return files
.filter((file) => file.endsWith(".jsonl"))
.map((file) => path.join(projectPath, file));
} catch (error) {
recordDiscoveryFailure(error, projectPath);
return [] as string[];
}
}),
)
).flat();
const statResults = await Promise.all(
fileEntries.map(async (fullPath) => {
try {
const fileStats = await fsPromises.stat(fullPath);
if (!fileStats.isFile()) return null;
return {
path: fullPath,
mtime: fileStats.mtime,
cacheKey: claudeSessionCacheKey(fullPath, fileStats),
};
} catch (error) {
recordDiscoveryFailure(error, fullPath);
return null;
}
}),
);
const candidates: ClaudeSessionCandidate[] = statResults.filter(
(entry): entry is ClaudeSessionCandidate => entry !== null,
);
if (candidates.length === 0 && permissionErrors.length > 0) {
throw new AggregateError(
permissionErrors,
`Cannot discover Claude sessions: access was denied to ${permissionErrors.length} paths and no transcripts could be discovered. ${permissionErrors[0]!.message}`,
);
}
return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit);
}
interface ClaudeSessionDescriptorAccumulator {
sessionId: string | null;
cwd: string | null;
customTitle: string | null;
aiTitle: string | null;
firstPromptTitle: string | null;
firstPromptPreview: string | null;
lastPromptPreview: string | null;
}
const CLAUDE_IMPORT_DESCRIPTOR_RECORD_PATTERN = /"type"\s*:\s*"(?:user|custom-title|ai-title)"/;
const CLAUDE_SESSION_ID_KEY_PATTERN = /"sessionId"\s*:/;
const CLAUDE_CWD_KEY_PATTERN = /"cwd"\s*:/;
function shouldParseClaudeSessionDescriptorLine(
line: string,
acc: ClaudeSessionDescriptorAccumulator,
): boolean {
return (
CLAUDE_IMPORT_DESCRIPTOR_RECORD_PATTERN.test(line) ||
(!acc.sessionId && CLAUDE_SESSION_ID_KEY_PATTERN.test(line)) ||
(!acc.cwd && CLAUDE_CWD_KEY_PATTERN.test(line))
);
}
function applyClaudeSessionEntryToAccumulator(
entryRaw: unknown,
acc: ClaudeSessionDescriptorAccumulator,
): void {
const entry = toObjectRecord(entryRaw);
if (!entry) {
return;
}
if (entry.isSidechain) {
return;
}
if (entry.type === "user" && isSyntheticUserEntry(entry)) {
return;
}
if (!acc.sessionId && typeof entry.sessionId === "string") {
acc.sessionId = entry.sessionId;
}
if (!acc.cwd && typeof entry.cwd === "string") {
acc.cwd = entry.cwd;
}
if (entry.type === "custom-title" && typeof entry.customTitle === "string") {
acc.customTitle = normalizeClaudeSessionTitle(entry.customTitle);
return;
}
if (entry.type === "ai-title" && typeof entry.aiTitle === "string") {
acc.aiTitle = normalizeClaudeSessionTitle(entry.aiTitle);
return;
}
if (entry.type === "user" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
acc.firstPromptTitle ??= text;
const preview = normalizeImportablePromptPreview(text);
acc.firstPromptPreview ??= preview;
acc.lastPromptPreview = preview;
}
return;
}
}
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date,
signal: AbortSignal,
): Promise<ImportableProviderSession | null> {
const acc: ClaudeSessionDescriptorAccumulator = {
sessionId: null,
cwd: null,
customTitle: null,
aiTitle: null,
firstPromptTitle: null,
firstPromptPreview: null,
lastPromptPreview: null,
};
// Keep only the current record, including image records larger than a read chunk.
const record: Buffer[] = [];
const stream = fs.createReadStream(filePath, { highWaterMark: 64 * 1024, signal });
function applyRecord(): void {
const line = Buffer.concat(record).toString("utf8");
if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) return;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
return;
}
applyClaudeSessionEntryToAccumulator(entry, acc);
}
try {
for await (const chunk of stream) {
signal.throwIfAborted();
let start = 0;
while (start < chunk.length) {
const newline = chunk.indexOf(10, start);
const end = newline === -1 ? chunk.length : newline;
record.push(chunk.subarray(start, end));
if (newline === -1) break;
applyRecord();
record.length = 0;
start = newline + 1;
}
}
if (record.length > 0) applyRecord();
} finally {
stream.destroy();
}
const { sessionId, cwd } = acc;
if (!sessionId || !cwd) {
return null;
}
return {
providerHandleId: sessionId,
cwd,
title:
acc.customTitle ??
acc.aiTitle ??
normalizeClaudeSessionTitle(acc.firstPromptTitle) ??
`Claude session ${sessionId.slice(0, 8)}`,
firstPromptPreview: acc.firstPromptPreview,
lastPromptPreview: acc.lastPromptPreview,
lastActivityAt: mtime,
};
}
function normalizeClaudeSessionTitle(title: string | null): string | null {
const normalized = title?.trim();
return normalized ? normalized : null;
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
*/
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
);
}
let end = stats.size;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
}
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
async function readClaudeRenamedSessionTitle(input: {
cwd: string;
sessionId: string;
configDir: string;
}): Promise<string | null> {
try {
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
if (isErrnoException(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function normalizeImportablePromptPreview(text: string): string | null {
const normalized = text.trim().replace(/\s+/g, " ");
if (!normalized) return null;
return normalized.length > 160 ? normalized.slice(0, 160) : normalized;
}
function normalizeClaudeUserPromptText(text: string): string | null {
const normalized = text.trim();
if (!CLAUDE_COMMAND_MESSAGE_PATTERN.test(normalized)) {
return normalized || null;
}
const command = readClaudeCommandPromptName(normalized);
if (!command) {
return null;
}
const commandArgs = normalized.match(CLAUDE_COMMAND_ARGS_PATTERN)?.[1]?.trim();
if (commandArgs) {
return `${command} ${commandArgs}`;
}
return command;
}
function readClaudeCommandPromptName(text: string): string | null {
const commandName = text.match(CLAUDE_COMMAND_NAME_PATTERN)?.[1]?.trim();
if (commandName) {
return commandName.startsWith("/") ? commandName : `/${commandName}`;
}
const commandMessage = text.match(CLAUDE_COMMAND_MESSAGE_PATTERN)?.[1]?.trim();
if (!commandMessage) {
return null;
}
return commandMessage.startsWith("/") ? commandMessage : `/${commandMessage}`;
}
function extractClaudeUserText(messageRaw: unknown): string | null {
const message = toObjectRecord(messageRaw);
if (!message) {
return null;
}
if (typeof message.content === "string") {
const normalized = message.content.trim();
if (!normalized || isClaudeTranscriptNoiseText(normalized)) return null;
return normalizeClaudeUserPromptText(normalized);
}
if (typeof message.text === "string") {
const normalized = message.text.trim();
if (!normalized || isClaudeTranscriptNoiseText(normalized)) return null;
return normalizeClaudeUserPromptText(normalized);
}
if (isUnknownArray(message.content)) {
for (const block of message.content) {
const blockRecord = toObjectRecord(block);
if (blockRecord && typeof blockRecord.text === "string") {
const normalized = blockRecord.text.trim();
if (normalized && !isClaudeTranscriptNoiseText(normalized)) {
return normalizeClaudeUserPromptText(normalized);
}
}
}
}
return null;
}
interface ClaudeCommandLifecycle {
commandUuid: string;
/** "cancelled" is terminal too: API errors, interrupts and "now" pre-emption end a message. */
state: "queued" | "started" | "completed" | "cancelled";
}
/** Runtime-only Claude frames are validated here because the SDK's public union omits them. */
function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | null {
const record = toObjectRecord(message);
if (record?.type !== "command_lifecycle") return null;
if (
typeof record.command_uuid !== "string" ||
!["queued", "started", "completed", "cancelled"].includes(String(record.state))
) {
return null;
}
return {
commandUuid: record.command_uuid,
state: record.state as ClaudeCommandLifecycle["state"],
};
}
async function transcriptExists(file: string): Promise<boolean> {
try {
await fs.promises.access(file);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}