Files
paseo/packages/server/src/server/agent/providers/claude-agent.ts
T

4188 lines
128 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
query,
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type Options,
type PermissionMode,
type PermissionResult,
type PermissionUpdate,
type Query,
type SpawnOptions,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
type SDKResultMessage,
type SDKSystemMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "pino";
import {
mapClaudeCanceledToolCall,
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./claude/tool-call-mapper.js";
import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
import {
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
type TurnState = "idle" | "foreground" | "autonomous";
type EventIdentifiers = {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
};
type AutonomousTurnState = {
id: string;
};
type AsyncMessageInput<T> = {
push: (item: T) => void;
end: () => void;
iterable: AsyncIterable<T>;
};
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const DEFAULT_MODES: AgentMode[] = [
{
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: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
},
{
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 INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
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;
type SlashCommandInvocation = {
commandName: string;
args?: string;
rawInput: string;
};
// Orchestrator instructions moved to shared module.
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
export type ClaudeContentChunk = { type: string; [key: string]: any };
type ClaudeOptions = Options;
type ClaudeAgentClientOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: typeof query;
};
type ClaudeAgentSessionOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
logger: Logger;
queryFactory?: typeof query;
};
type ClaudeThinkingEffort = "low" | "medium" | "high" | "max";
function resolveClaudeSpawnCommand(
spawnOptions: SpawnOptions,
runtimeSettings?: ProviderRuntimeSettings,
): { command: string; args: string[] } {
const commandConfig = runtimeSettings?.command;
if (!commandConfig || commandConfig.mode === "default") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args],
};
}
if (commandConfig.mode === "append") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args, ...(commandConfig.args ?? [])],
};
}
return {
command: commandConfig.argv[0]!,
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
};
}
function applyRuntimeSettingsToClaudeOptions(
options: ClaudeOptions,
runtimeSettings?: ProviderRuntimeSettings,
launchEnv?: Record<string, string>,
): ClaudeOptions {
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
// process.execPath — the actual node binary running the daemon. This avoids
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawn(
quoteWindowsCommand(command),
resolved.args.map((argument) => quoteWindowsArgument(argument)),
{
cwd: spawnOptions.cwd,
env: {
...applyProviderEnv(spawnOptions.env, runtimeSettings),
...(launchEnv ?? {}),
},
shell: process.platform === "win32",
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
},
);
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
options.stderr?.(chunk.toString());
});
}
return child;
},
};
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return value === "low" || value === "medium" || value === "high" || value === "max";
}
type 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;
};
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 = systemPromptRaw as Record<string, unknown>;
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,
};
}
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 as object;
if (seen.has(objectValue)) {
return "[circular]";
}
seen.add(objectValue);
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
normalized[key] = normalizeForDeterministicString(record[key], seen);
}
seen.delete(objectValue);
return normalized;
}
return String(value);
}
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 {
try {
return String(value);
} catch {
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 normalizeClaudeTranscriptText(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
function isClaudeTranscriptNoiseText(value: unknown): boolean {
return isClaudeInterruptPlaceholderText(value) || isClaudeNoResponsePlaceholderText(value);
}
function collectClaudeTextContentParts(content: unknown): string[] {
if (typeof content === "string") {
const normalized = normalizeClaudeTranscriptText(content);
return normalized ? [normalized] : [];
}
if (!Array.isArray(content)) {
return [];
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = normalizeClaudeTranscriptText((block as { text?: unknown }).text);
if (text) {
parts.push(text);
continue;
}
const input = normalizeClaudeTranscriptText((block as { input?: unknown }).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 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)) {
parts.push(trimmed);
}
continue;
}
const input = typeof block.input === "string" ? block.input : undefined;
if (input && input.trim()) {
const trimmed = input.trim();
if (!isClaudeTranscriptNoiseText(trimmed)) {
parts.push(trimmed);
}
}
}
if (parts.length === 0) {
return null;
}
const combined = parts.join("\n\n").trim();
return combined.length > 0 ? combined : null;
}
type PendingPermission = {
request: AgentPermissionRequest;
resolve: (result: PermissionResult) => void;
reject: (error: Error) => void;
cleanup?: () => void;
};
type ToolUseClassification = "generic" | "command" | "file_change";
type 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 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 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;
}
if (typeof metadata.approvalPolicy === "string") {
result.approvalPolicy = metadata.approvalPolicy;
}
if (typeof metadata.sandboxMode === "string") {
result.sandboxMode = metadata.sandboxMode;
}
if (typeof metadata.networkAccess === "boolean") {
result.networkAccess = metadata.networkAccess;
}
if (typeof metadata.webSearch === "boolean") {
result.webSearch = metadata.webSearch;
}
if (isMetadata(metadata.extra)) {
const extra: AgentSessionConfig["extra"] = {};
if (isMetadata(metadata.extra.codex)) {
extra.codex = metadata.extra.codex;
}
if (isClaudeExtra(metadata.extra.claude)) {
extra.claude = metadata.extra.claude;
}
if (extra.codex || extra.claude) {
result.extra = extra;
}
}
if (typeof metadata.systemPrompt === "string") {
result.systemPrompt = metadata.systemPrompt;
}
if (isMcpServersRecord(metadata.mcpServers)) {
result.mcpServers = metadata.mcpServers;
}
return result;
}
function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig {
switch (config.type) {
case "stdio":
return {
type: "stdio",
command: config.command,
args: config.args,
env: config.env,
};
case "http":
return {
type: "http",
url: config.url,
headers: config.headers,
};
case "sse":
return {
type: "sse",
url: config.url,
headers: config.headers,
};
}
}
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
return isMetadata(value) && typeof value.type === "string";
}
function isClaudeExtra(value: unknown): value is Partial<ClaudeOptions> {
return isMetadata(value);
}
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";
}
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") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
type TimelineFragment = {
kind: "assistant" | "reasoning";
text: string;
};
type 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 = message.event as Record<string, unknown>;
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 });
}
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 = message as unknown as {
message_id?: unknown;
message?: { id?: unknown } | null;
};
return (
readTrimmedString(candidate.message_id) ?? readTrimmedString(candidate.message?.id) ?? null
);
}
private readMessageIdFromStreamEvent(event: Record<string, unknown>): string | null {
const message = event.message as { id?: unknown } | undefined;
return readTrimmedString(event.message_id) ?? readTrimmedString(message?.id) ?? null;
}
}
function isSyntheticUserEntry(entry: unknown): boolean {
if (!entry || typeof entry !== "object") {
return false;
}
const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown };
return candidate.isSynthetic === true || candidate.isMeta === true;
}
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
const root = message as unknown as Record<string, unknown>;
const messageType = readTrimmedString(root.type);
const streamEvent = root.event as Record<string, unknown> | undefined;
const streamEventMessage = streamEvent?.message as Record<string, unknown> | undefined;
const messageContainer = root.message as Record<string, unknown> | undefined;
return {
taskId:
readTrimmedString(root.task_id) ??
readTrimmedString(streamEvent?.task_id) ??
readTrimmedString(streamEventMessage?.task_id) ??
readTrimmedString(messageContainer?.task_id) ??
null,
parentMessageId:
readTrimmedString(root.parent_message_id) ??
readTrimmedString(streamEvent?.parent_message_id) ??
readTrimmedString(streamEventMessage?.parent_message_id) ??
readTrimmedString(messageContainer?.parent_message_id) ??
null,
messageId:
readTrimmedString(root.message_id) ??
readTrimmedString(streamEvent?.message_id) ??
readTrimmedString(streamEventMessage?.id) ??
readTrimmedString(streamEventMessage?.message_id) ??
readTrimmedString(messageContainer?.id) ??
readTrimmedString(messageContainer?.message_id) ??
(messageType === "user" ? readTrimmedString(root.uuid) : null) ??
null,
};
}
export class ClaudeAgentClient implements AgentClient {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory: typeof query;
constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory ?? query;
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
});
}
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);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
});
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return getClaudeModels();
}
async listPersistedAgents(
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects");
if (!(await pathExists(projectsRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const candidates = await collectRecentClaudeSessions(projectsRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime);
if (descriptor) {
descriptors.push(descriptor);
}
if (descriptors.length >= limit) {
break;
}
}
return descriptors;
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
return fs.existsSync(command.argv[0]);
}
return true;
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const resolvedBinary = findExecutable("claude") ?? "not found";
const available = await this.isAvailable();
const version = resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
if (available) {
try {
const models = await this.listModels();
modelsValue = String(models.length);
} catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
}
return {
diagnostic: formatProviderDiagnostic("Claude Code", [
{ label: "Binary", value: resolvedBinary },
...(version ? [{ label: "Version", value: version }] : []),
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
};
} 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}'`);
}
return { ...config, provider: "claude" } as ClaudeAgentConfig;
}
}
function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null {
const command = runtimeSettings?.command;
try {
if (command?.mode === "replace") {
return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
encoding: "utf8",
timeout: 5_000,
}).trim() || null;
}
const executable = findExecutable("claude");
if (!executable) {
return null;
}
return execFileSync(executable, ["--version"], {
encoding: "utf8",
timeout: 5_000,
}).trim() || null;
} catch {
return null;
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
if (!modelUsage || typeof modelUsage !== "object") {
return undefined;
}
let maxContextWindow: number | undefined;
for (const value of Object.values(modelUsage as Record<string, unknown>)) {
if (!value || typeof value !== "object") {
continue;
}
const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
if (
typeof contextWindow !== "number" ||
!Number.isFinite(contextWindow) ||
contextWindow <= 0
) {
continue;
}
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
}
return maxContextWindow;
}
function readUsageTotalTokens(
usage: unknown,
): number | undefined {
if (!usage || typeof usage !== "object") {
return undefined;
}
const totalTokens = (usage as { total_tokens?: unknown }).total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
return totalTokens;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readUsageFromTaskNotification(message: { usage?: unknown }): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = (event.message as { usage?: unknown } | undefined)?.usage;
if (!messageUsage || typeof messageUsage !== "object") {
return undefined;
}
const usage = messageUsage as {
input_tokens?: unknown;
cache_creation_input_tokens?: unknown;
cache_read_input_tokens?: unknown;
};
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: undefined;
const cacheCreationInputTokens =
typeof usage.cache_creation_input_tokens === "number" &&
Number.isFinite(usage.cache_creation_input_tokens)
? usage.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;
if (typeof inputTokens !== "number" || inputTokens < 0) {
return undefined;
}
return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
const outputTokens = (event.usage as { output_tokens?: unknown } | undefined)?.output_tokens;
if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) {
return undefined;
}
return outputTokens;
}
class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly logger: Logger;
private readonly queryFactory: typeof query;
private query: Query | null = null;
private input: AsyncMessageInput<SDKUserMessage> | null = null;
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 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 sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
});
private persistedHistory: AgentTimelineItem[] = [];
private historyPending = 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 queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = [];
private recentStderr = "";
private closed = false;
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
this.launchEnv = options.launchEnv;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.logger = options.logger;
this.queryFactory = options.queryFactory ?? query;
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.loadPersistedHistory(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;
}
}
get id(): string | null {
return this.claudeSessionId;
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) {
return { ...this.cachedRuntimeInfo };
}
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
extra: {
runtimeModel: this.lastRuntimeModel,
},
}
: {}),
};
this.cachedRuntimeInfo = info;
return { ...info };
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
const eventTurnId = (event as { turnId?: string }).turnId;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
if (!finalText) {
finalText = event.item.text;
} else if (event.item.text.startsWith(finalText)) {
finalText = event.item.text;
} else {
finalText += event.item.text;
}
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await this.startTurn(prompt, options);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
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 {
sessionId: this.claudeSessionId,
finalText,
usage,
timeline,
};
}
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
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);
this.lastForegroundPromptText = this.extractPromptText(prompt);
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
let cancelIssued = false;
const requestCancel = () => {
if (cancelIssued) {
return;
}
cancelIssued = true;
if (this.cancelCurrentTurn === requestCancel) {
this.cancelCurrentTurn = null;
}
this.rejectAllPendingPermissions(new Error("Permission request aborted"));
this.finishForegroundTurn({
type: "turn_canceled",
provider: "claude",
reason: "Interrupted",
});
void this.interruptActiveTurn().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 (!this.input) {
throw new Error("Claude session input stream not initialized");
}
this.startQueryPump();
this.input.push(sdkMessage);
} catch (error) {
this.finishForegroundTurn(
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
);
}
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async interrupt(): Promise<void> {
if (this.cancelCurrentTurn) {
this.cancelCurrentTurn();
return;
}
if (this.autonomousTurn) {
this.flushPendingToolCalls();
this.completeAutonomousTurn();
}
await this.interruptActiveTurn();
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!this.historyPending || this.persistedHistory.length === 0) {
return;
}
const history = this.persistedHistory;
this.persistedHistory = [];
this.historyPending = false;
for (const item of history) {
yield { type: "timeline", item, provider: "claude" };
}
}
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";
const previousMode = this.currentMode;
const query = await this.ensureQuery();
await query.setPermissionMode(normalized);
if (normalized === "plan") {
if (previousMode !== "plan") {
this.planResumeMode = previousMode;
}
} else {
this.planResumeMode = normalized;
}
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const query = await this.ensureQuery();
await query.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
this.config.thinkingOptionId = undefined;
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) {
this.config.thinkingOptionId = normalizedThinkingOptionId;
} else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
this.queryRestartNeeded = true;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
}
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}'`);
}
this.pendingPermissions.delete(requestId);
pending.cleanup?.();
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const result: PermissionResult = {
behavior: "allow",
updatedInput: response.updatedInput ?? pending.request.input ?? {},
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
} else {
if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
}
const result: PermissionResult = {
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
};
pending.resolve(result);
}
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: response,
});
}
describePersistence(): AgentPersistenceHandle | null {
if (this.persistence) {
return this.persistence;
}
if (!this.claudeSessionId) {
return null;
}
this.persistence = {
provider: "claude",
sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId,
metadata: { ...this.config },
};
return this.persistence;
}
async close(): Promise<void> {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"Claude session close: start",
);
this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.autonomousTurn = null;
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.sidechainTracker.clear();
this.input?.end();
this.query?.close?.();
await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt");
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
this.query = null;
this.input = null;
this.logger.trace(
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
"Claude session close: completed",
);
}
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 (!commandMap.has(cmd.name)) {
commandMap.set(cmd.name, {
name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
});
}
}
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));
}
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 ? 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 query = await this.ensureFreshQuery();
return await query.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);
}
};
const historyIds = this.readUserMessageIdsFromHistoryFile();
for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(historyIds[idx]);
}
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
const item = this.persistedHistory[idx];
if (item?.type === "user_message") {
pushUnique(item.messageId);
}
}
for (let idx = this.userMessageIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(this.userMessageIds[idx]);
}
return candidates;
}
private readUserMessageIdsFromHistoryFile(): string[] {
if (!this.claudeSessionId) {
return [];
}
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
return [];
}
try {
const ids: string[] = [];
const content = fs.readFileSync(historyPath, "utf8");
for (const line of content.split(/\n+/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry?.type === "user" && typeof entry.uuid === "string") {
ids.push(entry.uuid);
}
} catch {
// ignore malformed lines
}
}
return ids;
} catch {
return [];
}
}
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 async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
}
if (this.queryRestartNeeded && this.query) {
const oldQuery = this.query;
const oldInput = this.input;
// 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;
// Reset session identity for explicit restarts so the new query starts
// a fresh session rather than resuming the previous one.
this.claudeSessionId = null;
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
}
// When the pump died unexpectedly (query became null, e.g. after a session
// ID overwrite error), preserve claudeSessionId so buildOptions() passes
// resume: sessionId and the new query auto-resumes the previous session.
// For explicit restarts above, claudeSessionId was already cleared.
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.query = this.queryFactory({ prompt: input.iterable, options });
// 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({ label }, "Claude query operation skipped (no promise)");
return;
}
const startedAt = Date.now();
this.logger.trace({ label }, "Claude query operation wait start");
try {
await Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), 3_000);
}),
]);
this.logger.trace(
{ label, durationMs: Date.now() - startedAt },
"Claude query operation settled",
);
} catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
}
}
private buildOptions(): ClaudeOptions {
const thinkingOptionId =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
let thinking: ClaudeOptions["thinking"];
let effort: ClaudeOptions["effort"];
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
thinking = { type: "adaptive" };
effort = thinkingOptionId;
}
const appendedSystemPrompt = [
getOrchestratorModeInstructions(),
this.config.systemPrompt?.trim(),
]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
const claudeBinary = findExecutable("claude");
this.logger.debug(
{
claudeBinary,
pathEnvKey:
process.env["Path"] !== undefined
? "Path"
: process.env["PATH"] !== undefined
? "PATH"
: null,
pathIncludesClaudeLocalBin:
(process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
permissionMode: this.currentMode,
// 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,
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions 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");
},
env: {
...process.env,
// Increase MCP timeouts for long-running tool calls (10 minutes)
MCP_TIMEOUT: "600000",
MCP_TOOL_TIMEOUT: "600000",
...(this.launchEnv ?? {}),
},
// 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.
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}),
...this.config.extra?.claude,
};
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) {
base.resume = this.claudeSessionId;
}
return this.applyRuntimeSettings(base);
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
}
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: string; data: string } }
> = [];
if (Array.isArray(prompt)) {
for (const chunk of prompt) {
if (chunk.type === "text") {
content.push({ type: "text", text: chunk.text });
} else if (chunk.type === "image") {
content.push({
type: "image",
source: {
type: "base64",
media_type: chunk.mimeType,
data: chunk.data,
},
});
}
}
} else {
content.push({ type: "text", text: prompt });
}
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 =
typeof error === "string" ? error : error instanceof Error ? error.message : "";
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 extractPromptText(prompt: AgentPromptInput): string | null {
if (typeof prompt === "string") {
return prompt;
}
const textParts = prompt
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text);
return textParts.length > 0 ? textParts.join("\n") : null;
}
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;
}
const message =
typeof error === "string"
? error
: error instanceof Error
? `${error.message}\n${error.stack ?? ""}`
: 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.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
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) {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.syncTurnState("autonomous turn terminal");
}
}
}
private startAutonomousTurn(): void {
if (this.autonomousTurn) {
return;
}
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
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.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]);
}
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
return;
}
const pump = this.runQueryPump().catch((error) => {
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
});
this.queryPumpPromise = pump;
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({ err: error }, "Failed to initialize Claude query pump");
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
try {
while (!this.closed && this.query === activeQuery) {
try {
for await (const message of activeQuery) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
},
"Claude query pump: raw SDK message",
);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) {
return;
}
this.routeSdkMessageFromPump(message);
}
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 routeSdkMessageFromPump(message: SDKMessage): void {
// 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;
}
}
if (
message.type === "result" &&
message.subtype !== "success" &&
this.isAbortError(message)
) {
this.logger.debug("Suppressing abort result by content");
return;
}
const isForeground = Boolean(this.activeForegroundTurnId);
const assistantishMessage =
message.type === "assistant" ||
message.type === "stream_event" ||
message.type === "tool_progress" ||
(message.type === "system" && message.subtype === "task_notification");
if (!isForeground && assistantishMessage) {
this.startAutonomousTurn();
}
if (!isForeground && !this.autonomousTurn && message.type === "result") {
return;
}
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
const assistantTimelineEvents = this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint: identifiers.messageId,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
// User message dedup: suppress echoed user messages that match the foreground prompt
const filteredMessageEvents = messageEvents.filter((event) => {
if (
event.type === "timeline" &&
event.item.type === "user_message" &&
this.activeForegroundTurnId &&
this.lastForegroundPromptText
) {
if (event.item.text.trim() === this.lastForegroundPromptText.trim()) {
return false;
}
}
return true;
});
const events = [...filteredMessageEvents, ...assistantTimelineEvents];
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 (
this.activeForegroundTurnId &&
events.some(
(event) =>
event.type === "timeline" ||
event.type === "permission_requested" ||
event.type === "permission_resolved",
)
) {
this.foregroundHasVisibleActivity = true;
}
this.dispatchEvents(events);
}
private async handleMissingResumedConversation(
message: SDKMessage,
query: Query,
): Promise<boolean> {
const staleResumeError = this.readMissingResumedConversationError(message);
if (!staleResumeError) {
return false;
}
this.logger.warn(
{
claudeSessionId: this.claudeSessionId,
error: staleResumeError,
},
"Claude resumed session no longer exists; invalidating persisted session",
);
this.failActiveTurns(staleResumeError);
this.input?.end();
await this.awaitWithTimeout(
query.return?.(),
"query pump return on missing resumed conversation",
);
if (this.query === query) {
this.query = null;
this.input = null;
}
this.claudeSessionId = null;
this.persistence = null;
this.persistedHistory = [];
this.historyPending = false;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = false;
this.autonomousTurn = null;
this.activeForegroundTurnId = null;
this.syncTurnState("missing resumed conversation");
return true;
}
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace("interruptActiveTurn: no query to interrupt");
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");
}
}
private translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentStreamEvent[] {
const parentToolUseId =
"parent_tool_use_id" in message
? (message as { parent_tool_use_id: string | null }).parent_tool_use_id
: null;
if (parentToolUseId) {
return this.sidechainTracker.handleMessage(message, parentToolUseId);
}
const events: AgentStreamEvent[] = [];
const fallbackThreadSessionId = this.captureSessionIdFromMessage(message);
if (fallbackThreadSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: fallbackThreadSessionId,
});
}
switch (message.type) {
case "system":
if (message.subtype === "init") {
const threadSessionId = this.handleSystemMessage(message);
if (threadSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: threadSessionId,
});
}
} else if (message.subtype === "status") {
const status = (message as { status?: string }).status;
if (status === "compacting") {
this.compacting = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
}
} else if (message.subtype === "compact_boundary") {
const compactMetadata = readCompactionMetadata(message as Record<string, unknown>);
events.push({
type: "timeline",
item: {
type: "compaction",
status: "completed",
trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
},
provider: "claude",
});
} else if (message.subtype === "task_notification") {
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
if (typeof this.lastContextWindowUsedTokens === "number") {
events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens));
}
}
break;
case "user": {
if (isSyntheticUserEntry(message)) {
break;
}
if (this.compacting) {
this.compacting = false;
break;
}
const messageId =
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
this.rememberUserMessageId(messageId);
const content = message.message?.content;
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId,
});
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
break;
}
if (typeof content === "string" && content.length > 0) {
if (!isClaudeTranscriptNoiseText(content)) {
events.push({
type: "timeline",
item: {
type: "user_message",
text: content,
...(messageId ? { messageId } : {}),
},
provider: "claude",
});
}
} else if (Array.isArray(content)) {
const timelineItems = 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" });
}
}
break;
}
case "assistant": {
const timelineItems = 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" });
}
break;
}
case "stream_event": {
const usageUpdatedEvent = this.trackStreamEventUsage(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
break;
}
case "result": {
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage });
} else {
const errorMessage =
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
events.push(this.buildTurnFailedEvent(errorMessage));
}
break;
}
default:
break;
}
return events;
}
private captureSessionIdFromMessage(message: SDKMessage): string | null {
const msg = message as unknown as {
session_id?: unknown;
sessionId?: unknown;
session?: { id?: unknown } | null;
};
const sessionIdRaw =
typeof msg.session_id === "string"
? msg.session_id
: typeof msg.sessionId === "string"
? msg.sessionId
: typeof msg.session?.id === "string"
? msg.session.id
: "";
const sessionId = sessionIdRaw.trim();
if (!sessionId) {
return null;
}
if (this.claudeSessionId === null) {
this.claudeSessionId = sessionId;
this.persistence = null;
return sessionId;
}
if (this.claudeSessionId === sessionId) {
return null;
}
// 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.persistence = null;
return sessionId;
}
private handleSystemMessage(message: SDKSystemMessage): string | null {
if (message.subtype !== "init") {
return null;
}
const msg = message as unknown as {
session_id?: unknown;
sessionId?: unknown;
session?: { id?: unknown } | null;
};
const newSessionIdRaw =
typeof msg.session_id === "string"
? msg.session_id
: typeof msg.sessionId === "string"
? msg.sessionId
: typeof msg.session?.id === "string"
? msg.session.id
: "";
const newSessionId = newSessionIdRaw.trim();
if (!newSessionId) {
return null;
}
const existingSessionId = this.claudeSessionId;
let threadStartedSessionId: string | null = null;
if (existingSessionId === null) {
this.claudeSessionId = newSessionId;
threadStartedSessionId = newSessionId;
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
} else if (existingSessionId === newSessionId) {
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;
threadStartedSessionId = newSessionId;
}
this.availableModes = DEFAULT_MODES;
this.currentMode = message.permissionMode;
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
this.persistence = null;
if (message.model) {
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;
}
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, modelUsage?: unknown): AgentUsage | undefined {
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 contextWindowMaxTokens = extractContextWindowSize(
modelUsage ?? message.modelUsage,
);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (
typeof this.lastStreamRequestInputTokens === "number" &&
typeof this.lastStreamRequestOutputTokens === "number"
) {
usage.contextWindowUsedTokens =
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens;
} else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but
// for the first turn they equal the per-call values so the estimate is
// reasonable. Once a task_progress arrives it takes over permanently.
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};
const derived =
(message.usage.input_tokens ?? 0) +
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.output_tokens ?? 0);
if (Number.isFinite(derived) && derived > 0) {
usage.contextWindowUsedTokens = derived;
}
}
return usage;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
private trackStreamEventUsage(event: unknown): AgentStreamEvent | null {
if (!event || typeof event !== "object") {
return null;
}
const streamEvent = event as Record<string, unknown>;
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.lastStreamRequestInputTokens = inputTokens;
this.lastStreamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.lastStreamRequestOutputTokens = outputTokens;
} else {
return null;
}
if (
typeof this.lastStreamRequestInputTokens !== "number" ||
typeof this.lastStreamRequestOutputTokens !== "number"
) {
return null;
}
return this.createUsageUpdatedEvent(
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens,
);
}
private handlePermissionRequest: CanUseTool = async (
toolName,
input,
options,
): Promise<PermissionResult> => {
const requestId = `permission-${randomUUID()}`;
const kind = resolvePermissionKind(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,
input,
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 });
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();
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();
}
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);
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
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);
}
}
private loadPersistedHistory(sessionId: string): void {
try {
const historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
return;
}
this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8"));
} catch (error) {
// ignore history load failures
}
}
private ingestPersistedHistory(content: string): void {
if (!content) {
return;
}
const timeline: AgentTimelineItem[] = [];
for (const line of content.split(/\r?\n/)) {
this.ingestPersistedHistoryLine(line, timeline);
}
if (timeline.length > 0) {
this.persistedHistory = [...this.persistedHistory, ...timeline];
this.historyPending = true;
}
}
private ingestPersistedHistoryLine(line: string, timeline: AgentTimelineItem[]): void {
const trimmed = line.trim();
if (!trimmed) {
return;
}
let entry: Record<string, unknown>;
try {
entry = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return;
}
if (entry.isSidechain) {
return;
}
if (entry.type === "user" && typeof entry.uuid === "string") {
this.rememberUserMessageId(entry.uuid);
}
const items = this.convertHistoryEntry(entry);
if (items.length > 0) {
timeline.push(...items);
}
}
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
// Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const dir = path.join(configDir, "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`);
}
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content));
}
// 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 mapBlocksToTimeline(
content: string | ClaudeContentChunk[],
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): 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) {
switch (block.type) {
case "text":
case "text_delta":
if (
block.text &&
block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER &&
!isClaudeTranscriptNoiseText(block.text)
) {
if (textMessageType === "user_message") {
const trimmed = block.text.trim();
if (trimmed) {
userTextParts.push(trimmed);
}
} else if (!suppressText) {
items.push({ type: "assistant_message", text: block.text });
}
}
break;
case "thinking":
case "thinking_delta":
if (block.thinking) {
if (!suppressReasoning) {
items.push({ type: "reasoning", text: block.thinking });
}
}
break;
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
this.handleToolUseStart(block, 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": {
this.handleToolResult(block, items);
break;
}
default:
break;
}
}
if (textMessageType === "user_message" && userTextParts.length > 0) {
items.unshift({
type: "user_message",
text: userTextParts.join("\n\n"),
});
}
return items;
}
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 handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry =
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const toolName = entry?.name ?? block.tool_name ?? "tool";
const callId =
typeof block.tool_use_id === "string" && block.tool_use_id.length > 0
? block.tool_use_id
: (entry?.id ?? null);
// Extract output from block.content (SDK always returns content in string form)
const output = this.buildToolOutput(block, entry);
if (block.is_error) {
this.pushToolCall(
mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: block,
}),
items,
);
} else {
this.pushToolCall(
mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
}),
items,
);
}
if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id);
this.sidechainTracker.delete(block.tool_use_id);
}
}
private buildToolOutput(
block: ClaudeContentChunk,
entry: ToolUseCacheEntry | undefined,
): AgentMetadata | undefined {
if (block.is_error) {
return undefined;
}
const server = entry?.server ?? block.server ?? "tool";
const tool = entry?.name ?? block.tool_name ?? "tool";
const content = coerceToolResultContentToString(block.content);
const input = entry?.input;
// Build structured result based on tool type
const structured = this.buildStructuredToolResult(server, tool, content, input);
if (structured) {
return structured;
}
// Fallback format - try to parse JSON first
const result: AgentMetadata = {};
if (content.length > 0) {
try {
// If content is a JSON string, parse it
result.output = JSON.parse(content);
} catch {
// If not JSON, return unchanged (no extra wrapping)
result.output = content;
}
}
// Preserve file changes tracked during tool execution
if (entry?.files?.length) {
result.files = entry.files;
}
return Object.keys(result).length > 0 ? result : undefined;
}
private buildStructuredToolResult(
server: string,
tool: string,
output: string,
input?: AgentMetadata | null,
): AgentMetadata | undefined {
const normalizedServer = server.toLowerCase();
const normalizedTool = tool.toLowerCase();
// Command execution tools
if (
normalizedServer.includes("bash") ||
normalizedServer.includes("shell") ||
normalizedServer.includes("command") ||
normalizedTool.includes("bash") ||
normalizedTool.includes("shell") ||
normalizedTool.includes("command") ||
(input && (typeof input.command === "string" || Array.isArray(input.command)))
) {
const command = this.extractCommandText(input ?? {}) ?? "command";
return {
type: "command",
command,
output,
cwd: typeof input?.cwd === "string" ? input.cwd : undefined,
};
}
// File write tools (new files or complete replacements)
if (
normalizedTool.includes("write") ||
normalizedTool === "write_file" ||
normalizedTool === "create_file"
) {
if (input && typeof input.file_path === "string") {
return {
type: "file_write",
filePath: input.file_path,
oldContent: "",
newContent: typeof input.content === "string" ? input.content : output,
};
}
}
// File edit/patch tools
if (
normalizedTool.includes("edit") ||
normalizedTool.includes("patch") ||
normalizedTool === "apply_patch" ||
normalizedTool === "apply_diff"
) {
if (input && typeof input.file_path === "string") {
// Support both old_str/new_str and old_string/new_string parameter names
const oldContent =
typeof input.old_str === "string"
? input.old_str
: typeof input.old_string === "string"
? input.old_string
: undefined;
const newContent =
typeof input.new_str === "string"
? input.new_str
: typeof input.new_string === "string"
? input.new_string
: undefined;
return {
type: "file_edit",
filePath: input.file_path,
diff:
typeof input.patch === "string"
? input.patch
: typeof input.diff === "string"
? input.diff
: undefined,
oldContent,
newContent,
};
}
}
// File read tools
if (
normalizedTool.includes("read") ||
normalizedTool === "read_file" ||
normalizedTool === "view_file"
) {
if (input && typeof input.file_path === "string") {
return {
type: "file_read",
filePath: input.file_path,
content: output,
};
}
}
return undefined;
}
private mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentTimelineItem[] {
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);
}
} else 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 [];
}
} else 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);
}
}
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? 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) ??
({
id,
name: typeof block.name === "string" && block.name.length > 0 ? block.name : "tool",
server:
typeof block.server === "string" && block.server.length > 0
? block.server
: typeof block.name === "string" && block.name.length > 0
? block.name
: "tool",
classification: "generic",
started: false,
} satisfies ToolUseCacheEntry);
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 {
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: Record<string, unknown>,
): { trigger?: string; preTokens?: number } | null {
const candidates = [source.compact_metadata, source.compactMetadata, source.compactionMetadata];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const metadata = candidate as Record<string, unknown>;
const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined;
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined;
return { trigger, preTokens };
}
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;
}
export function convertClaudeHistoryEntry(
entry: any,
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
if (entry.type === "system" && entry.subtype === "compact_boundary") {
const compactMetadata = readCompactionMetadata(entry as Record<string, unknown>);
return [
{
type: "compaction",
status: "completed",
trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto",
preTokens: compactMetadata?.preTokens,
},
];
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry);
if (taskNotificationItem) {
return [taskNotificationItem];
}
if (entry.isCompactSummary) {
return [];
}
if (entry.type === "user" && isSyntheticUserEntry(entry)) {
return [];
}
const message = entry?.message;
if (!message || !("content" in message)) {
return [];
}
const content = message.content;
if (
(entry.type === "user" || entry.type === "assistant") &&
isClaudeTranscriptNoiseContent(content)
) {
return [];
}
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 taskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId: userMessageId,
});
if (taskNotificationItem) {
return [taskNotificationItem];
}
}
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 = mapBlocks(normalizedBlocks);
if (entry.type === "user") {
const toolItems = mapped.filter((item) => item.type === "tool_call");
return timeline.length ? [...timeline, ...toolItems] : toolItems;
}
return mapped;
}
if (entry.type === "assistant" && contentValue) {
return mapBlocks(contentValue);
}
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);
});
},
};
},
},
};
}
type ClaudeSessionCandidate = {
path: string;
mtime: Date;
};
async function pathExists(target: string): Promise<boolean> {
try {
await fsPromises.access(target);
return true;
} catch {
return false;
}
}
async function collectRecentClaudeSessions(
root: string,
limit: number,
): Promise<ClaudeSessionCandidate[]> {
let projectDirs: string[];
try {
projectDirs = await fsPromises.readdir(root);
} catch {
return [];
}
const candidates: ClaudeSessionCandidate[] = [];
for (const dirName of projectDirs) {
const projectPath = path.join(root, dirName);
let stats: fs.Stats;
try {
stats = await fsPromises.stat(projectPath);
} catch {
continue;
}
if (!stats.isDirectory()) {
continue;
}
let files: string[];
try {
files = await fsPromises.readdir(projectPath);
} catch {
continue;
}
for (const file of files) {
if (!file.endsWith(".jsonl")) {
continue;
}
const fullPath = path.join(projectPath, file);
try {
const fileStats = await fsPromises.stat(fullPath);
candidates.push({ path: fullPath, mtime: fileStats.mtime });
} catch {
// ignore stat errors for individual files
}
}
}
return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit);
}
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date,
): Promise<PersistedAgentDescriptor | null> {
let content: string;
try {
content = await fsPromises.readFile(filePath, "utf8");
} catch {
return null;
}
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
const timeline: AgentTimelineItem[] = [];
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry?.isSidechain) {
continue;
}
if (entry?.type === "user" && isSyntheticUserEntry(entry)) {
continue;
}
if (!sessionId && typeof entry.sessionId === "string") {
sessionId = entry.sessionId;
}
if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd;
}
if (entry.type === "user" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
if (!title) {
title = text;
}
timeline.push({ type: "user_message", text });
}
} else if (entry.type === "assistant" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
timeline.push({ type: "assistant_message", text });
}
}
if (sessionId && cwd && title) {
break;
}
}
if (!sessionId || !cwd) {
return null;
}
const persistence: AgentPersistenceHandle = {
provider: "claude",
sessionId,
nativeHandle: sessionId,
metadata: {
provider: "claude",
cwd,
},
};
return {
provider: "claude",
sessionId,
cwd,
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
timeline,
};
}
function extractClaudeUserText(message: any): string | null {
if (!message) {
return null;
}
if (typeof message.content === "string") {
const normalized = message.content.trim();
return normalized && !isClaudeTranscriptNoiseText(normalized) ? normalized : null;
}
if (typeof message.text === "string") {
const normalized = message.text.trim();
return normalized && !isClaudeTranscriptNoiseText(normalized) ? normalized : null;
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block && typeof block.text === "string") {
const normalized = block.text.trim();
if (normalized && !isClaudeTranscriptNoiseText(normalized)) {
return normalized;
}
}
}
}
return null;
}