import { ContentBlock, PlanEntry, ToolCallContent, ToolCallLocation, ToolKind, } from "@agentclientprotocol/sdk"; import { HookCallback } from "@anthropic-ai/claude-agent-sdk"; import { AgentInput, BashInput, FileEditInput, FileReadInput, FileWriteInput, GlobInput, GrepInput, TaskCreateInput, TaskCreateOutput, TaskUpdateInput, TodoWriteInput, WebFetchInput, WebSearchInput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; import { ImageBlockParam, TextBlockParam, ToolResultBlockParam, WebSearchResultBlock, WebSearchToolResultBlockParam, WebSearchToolResultError, } from "@anthropic-ai/sdk/resources"; import { BetaBashCodeExecutionResultBlock, BetaBashCodeExecutionToolResultBlockParam, BetaBashCodeExecutionToolResultError, BetaCodeExecutionResultBlock, BetaCodeExecutionToolResultBlockParam, BetaCodeExecutionToolResultError, BetaImageBlockParam, BetaRequestMCPToolResultBlockParam, BetaTextEditorCodeExecutionCreateResultBlock, BetaTextEditorCodeExecutionStrReplaceResultBlock, BetaTextEditorCodeExecutionToolResultBlockParam, BetaTextEditorCodeExecutionToolResultError, BetaTextEditorCodeExecutionViewResultBlock, BetaToolReferenceBlock, BetaToolResultBlockParam, BetaToolSearchToolResultBlockParam, BetaToolSearchToolResultError, BetaToolSearchToolSearchResultBlock, BetaWebFetchBlock, BetaWebFetchToolResultBlockParam, BetaWebFetchToolResultErrorBlock, BetaWebSearchToolResultBlockParam, } from "@anthropic-ai/sdk/resources/beta.mjs"; import path from "node:path"; import { Logger } from "./acp-agent.js"; /** * Union of all possible content types that can appear in tool results from the Anthropic SDK. * These are transformed to valid ACP ContentBlock types by toValidAcpContent(). */ type ToolResultContent = | TextBlockParam | ImageBlockParam | BetaImageBlockParam | BetaToolReferenceBlock | BetaToolSearchToolSearchResultBlock | BetaToolSearchToolResultError | WebSearchResultBlock | WebSearchToolResultError | BetaWebFetchBlock | BetaWebFetchToolResultErrorBlock | BetaCodeExecutionResultBlock | BetaCodeExecutionToolResultError | BetaBashCodeExecutionResultBlock | BetaBashCodeExecutionToolResultError | BetaTextEditorCodeExecutionViewResultBlock | BetaTextEditorCodeExecutionCreateResultBlock | BetaTextEditorCodeExecutionStrReplaceResultBlock | BetaTextEditorCodeExecutionToolResultError; interface ToolInfo { title: string; kind: ToolKind; content: ToolCallContent[]; locations?: ToolCallLocation[]; } interface ToolUpdate { title?: string; content?: ToolCallContent[]; locations?: ToolCallLocation[]; _meta?: { terminal_info?: { terminal_id: string; }; terminal_output?: { terminal_id: string; data: string; }; terminal_exit?: { terminal_id: string; exit_code: number; signal: string | null; }; }; } /** * Convert an absolute file path to a project-relative path for display. * Returns the original path if it's outside the project directory or if no cwd is provided. */ export function toDisplayPath(filePath: string, cwd?: string): string { if (!cwd) return filePath; const resolvedCwd = path.resolve(cwd); const resolvedFile = path.resolve(filePath); if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) { return path.relative(resolvedCwd, resolvedFile); } return filePath; } export function toolInfoFromToolUse( toolUse: any, supportsTerminalOutput: boolean = false, cwd?: string, ): ToolInfo { const name = toolUse.name; switch (name) { case "Agent": case "Task": { const input = toolUse.input as AgentInput | BashInput | undefined; return { title: input?.description ? input.description : "Task", kind: "think", content: input && "prompt" in input ? [ { type: "content", content: { type: "text", text: input.prompt }, }, ] : [], }; } case "Bash": { const input = toolUse.input as BashInput | undefined; return { title: input?.command ? input.command : "Terminal", kind: "execute", content: supportsTerminalOutput ? [{ type: "terminal" as const, terminalId: toolUse.id }] : input && input.description ? [ { type: "content", content: { type: "text", text: input.description }, }, ] : [], }; } case "Read": { const input = toolUse.input as FileReadInput | undefined; let limit = ""; if (input?.limit && input.limit > 0) { limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")"; } else if (input?.offset) { limit = " (from line " + input.offset + ")"; } const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File"; return { title: "Read " + displayPath + limit, kind: "read", locations: input?.file_path ? [ { path: input.file_path, line: input.offset ?? 1, }, ] : [], content: [], }; } case "Write": { const input = toolUse.input as FileWriteInput | undefined; let content: ToolCallContent[] = []; if (input && input.file_path) { content = [ { type: "diff", path: input.file_path, oldText: null, newText: input.content, }, ]; } else if (input && input.content) { content = [ { type: "content", content: { type: "text", text: input.content }, }, ]; } const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined; return { title: displayPath ? `Write ${displayPath}` : "Write", kind: "edit", content, locations: input?.file_path ? [{ path: input.file_path }] : [], }; } case "Edit": { const input = toolUse.input as FileEditInput | undefined; let content: ToolCallContent[] = []; if (input && input.file_path && (input.old_string || input.new_string)) { content = [ { type: "diff", path: input.file_path, oldText: input.old_string || null, newText: input.new_string ?? "", }, ]; } const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined; return { title: displayPath ? `Edit ${displayPath}` : "Edit", kind: "edit", content, locations: input?.file_path ? [{ path: input.file_path }] : [], }; } case "Glob": { const input = toolUse.input as GlobInput | undefined; let label = "Find"; if (input?.path) { label += ` \`${input.path}\``; } if (input?.pattern) { label += ` \`${input.pattern}\``; } return { title: label, kind: "search", content: [], locations: input?.path ? [{ path: input.path }] : [], }; } case "Grep": { const input = toolUse.input as GrepInput | undefined; let label = "grep"; if (input?.["-i"]) { label += " -i"; } if (input?.["-n"]) { label += " -n"; } if (input?.["-A"] !== undefined) { label += ` -A ${input["-A"]}`; } if (input?.["-B"] !== undefined) { label += ` -B ${input["-B"]}`; } if (input?.["-C"] !== undefined) { label += ` -C ${input["-C"]}`; } if (input?.output_mode) { switch (input.output_mode) { case "files_with_matches": label += " -l"; break; case "count": label += " -c"; break; case "content": default: break; } } if (input?.head_limit !== undefined) { label += ` | head -${input.head_limit}`; } if (input?.glob) { label += ` --include="${input.glob}"`; } if (input?.type) { label += ` --type=${input.type}`; } if (input?.multiline) { label += " -P"; } if (input?.pattern) { label += ` "${input.pattern}"`; } if (input?.path) { label += ` ${input.path}`; } return { title: label, kind: "search", content: [], }; } case "WebFetch": { const input = toolUse.input as WebFetchInput; return { title: input?.url ? `Fetch ${input.url}` : "Fetch", kind: "fetch", content: input && input.prompt ? [ { type: "content", content: { type: "text", text: input.prompt }, }, ] : [], }; } case "WebSearch": { const input = toolUse.input as WebSearchInput | undefined; let label = input?.query ? `"${input.query}"` : "Web search"; if (input?.allowed_domains && input.allowed_domains.length > 0) { label += ` (allowed: ${input.allowed_domains.join(", ")})`; } if (input?.blocked_domains && input.blocked_domains.length > 0) { label += ` (blocked: ${input.blocked_domains.join(", ")})`; } return { title: label, kind: "fetch", content: [], }; } case "TodoWrite": { const input = toolUse.input as TodoWriteInput | undefined; return { title: Array.isArray(input?.todos) ? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}` : "Update TODOs", kind: "think", content: [], }; } case "TaskCreate": { const input = toolUse.input as TaskCreateInput | undefined; return { title: input?.subject ? `Create task: ${input.subject}` : "Create task", kind: "think", content: [], }; } case "TaskUpdate": { const input = toolUse.input as TaskUpdateInput | undefined; return { title: input?.subject ? `Update task: ${input.subject}` : "Update task", kind: "think", content: [], }; } case "TaskList": { return { title: "List tasks", kind: "think", content: [], }; } case "TaskGet": { return { title: "Get task", kind: "think", content: [], }; } case "ExitPlanMode": { const planInput = toolUse.input as { plan?: string } | undefined; return { title: "Ready to code?", kind: "switch_mode", content: planInput?.plan ? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }] : [], }; } case "Other": { const input = toolUse.input; let output; try { output = JSON.stringify(input, null, 2); } catch { output = typeof input === "string" ? input : "{}"; } return { title: name || "Unknown Tool", kind: "other", content: [ { type: "content", content: { type: "text", text: `\`\`\`json\n${output}\`\`\``, }, }, ], }; } default: return { title: name || "Unknown Tool", kind: "other", content: [], }; } } export function toolUpdateFromToolResult( toolResult: | ToolResultBlockParam | BetaToolResultBlockParam | BetaWebSearchToolResultBlockParam | BetaWebFetchToolResultBlockParam | WebSearchToolResultBlockParam | BetaCodeExecutionToolResultBlockParam | BetaBashCodeExecutionToolResultBlockParam | BetaTextEditorCodeExecutionToolResultBlockParam | BetaRequestMCPToolResultBlockParam | BetaToolSearchToolResultBlockParam, toolUse: any | undefined, supportsTerminalOutput: boolean = false, ): ToolUpdate { if ( "is_error" in toolResult && toolResult.is_error && toolResult.content && toolResult.content.length > 0 ) { // Only return errors return toAcpContentUpdate(toolResult.content, true); } switch (toolUse?.name) { case "Read": if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { return { content: toolResult.content.map((content: any) => ({ type: "content", content: content.type === "text" ? { type: "text", text: markdownEscape(content.text), } : toAcpContentBlock(content, false), })), }; } else if (typeof toolResult.content === "string" && toolResult.content.length > 0) { return { content: [ { type: "content", content: { type: "text", text: markdownEscape(toolResult.content), }, }, ], }; } return {}; case "Bash": { const result = toolResult.content; const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : ""; const isError = "is_error" in toolResult && toolResult.is_error; // Extract output and exit code from either format: // 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code } // 2. Plain string content from a regular tool_result // 3. Array content (e.g. [{ type: "text", text: "..." }]) let output = ""; let exitCode = isError ? 1 : 0; if ( result && typeof result === "object" && "type" in result && result.type === "bash_code_execution_result" ) { const bashResult = result as BetaBashCodeExecutionResultBlock; output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n"); exitCode = bashResult.return_code; } else if (typeof result === "string") { output = result; } else if ( Array.isArray(result) && result.length > 0 && "text" in result[0] && typeof result[0].text === "string" ) { output = result.map((c: any) => c.text).join("\n"); } if (supportsTerminalOutput) { return { content: [{ type: "terminal" as const, terminalId }], _meta: { terminal_info: { terminal_id: terminalId, }, terminal_output: { terminal_id: terminalId, data: output, }, terminal_exit: { terminal_id: terminalId, exit_code: exitCode, signal: null, }, }, }; } // Fallback: format output as a code block without terminal _meta if (output.trim()) { return { content: [ { type: "content", content: { type: "text", text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, }, }, ], }; } return {}; } case "Edit": // Edit is handled in hooks case "Write": { return {}; } case "ExitPlanMode": { return { title: "Exited Plan Mode" }; } default: { return toAcpContentUpdate( toolResult.content, "is_error" in toolResult ? toolResult.is_error : false, ); } } } function toAcpContentUpdate( content: any, isError: boolean = false, ): { content?: ToolCallContent[] } { if (Array.isArray(content) && content.length > 0) { return { content: content.map((c: any) => ({ type: "content" as const, content: toAcpContentBlock(c, isError), })), }; } else if (typeof content === "object" && content !== null && "type" in content) { return { content: [ { type: "content" as const, content: toAcpContentBlock(content, isError), }, ], }; } else if (typeof content === "string" && content.length > 0) { return { content: [ { type: "content", content: { type: "text", text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, }, }, ], }; } return {}; } function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock { const wrapText = (text: string): ContentBlock => ({ type: "text" as const, text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, }); switch (content.type) { case "text": return { type: "text" as const, text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text, }; case "image": if (content.source.type === "base64") { return { type: "image" as const, data: content.source.data, mimeType: content.source.media_type, }; } // URL and file-based images can't be converted to ACP format (requires data) return wrapText( content.source.type === "url" ? `[image: ${content.source.url}]` : "[image: file reference]", ); case "tool_reference": return wrapText(`Tool: ${content.tool_name}`); case "tool_search_tool_search_result": return wrapText( `Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`, ); case "tool_search_tool_result_error": return wrapText( `Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`, ); case "web_search_result": return wrapText(`${content.title} (${content.url})`); case "web_search_tool_result_error": return wrapText(`Error: ${content.error_code}`); case "web_fetch_result": return wrapText(`Fetched: ${content.url}`); case "web_fetch_tool_result_error": return wrapText(`Error: ${content.error_code}`); case "code_execution_result": return wrapText(`Output: ${content.stdout || content.stderr || ""}`); case "bash_code_execution_result": return wrapText(`Output: ${content.stdout || content.stderr || ""}`); case "code_execution_tool_result_error": case "bash_code_execution_tool_result_error": return wrapText(`Error: ${content.error_code}`); case "text_editor_code_execution_view_result": return wrapText(content.content); case "text_editor_code_execution_create_result": return wrapText(content.is_file_update ? "File updated" : "File created"); case "text_editor_code_execution_str_replace_result": return wrapText(content.lines?.join("\n") || ""); case "text_editor_code_execution_tool_result_error": return wrapText( `Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`, ); default: return wrapText(JSON.stringify(content)); } } export type ClaudePlanEntry = { content: string; status: "pending" | "in_progress" | "completed"; activeForm: string; }; export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] { return (input?.todos ?? []).map((todo) => ({ content: todo.content, status: todo.status, priority: "medium", })); } /** * Per-session task list accumulated from Task* tool calls (TaskCreate / * TaskUpdate). The headless/SDK session emits these as incremental tool * calls keyed by task ID, replacing the snapshot-style TodoWrite tool. * Iteration order is insertion order (Map semantics), matching the order * tasks are created. */ export type TaskEntry = { subject: string; status: "pending" | "in_progress" | "completed"; activeForm?: string; description?: string; }; export type TaskState = Map; /** * Best-effort parse of a TaskCreate tool_result content into the structured * TaskCreateOutput. The SDK delivers tool outputs either as a string or as * an array of TextBlockParam-like blocks containing JSON text; try both. */ export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined { const tryParse = (text: string): TaskCreateOutput | undefined => { try { const parsed = JSON.parse(text); if ( parsed && typeof parsed === "object" && parsed.task && typeof parsed.task.id === "string" ) { return parsed as TaskCreateOutput; } } catch { // ignore } return undefined; }; if (typeof content === "string") { return tryParse(content); } if (Array.isArray(content)) { for (const block of content) { if (block && typeof block === "object" && "type" in block && block.type === "text") { const text = (block as { text?: unknown }).text; if (typeof text === "string") { const parsed = tryParse(text); if (parsed) return parsed; } } } } return undefined; } export function applyTaskCreate( state: TaskState, input: TaskCreateInput | undefined, output: TaskCreateOutput | undefined, ): void { const taskId = output?.task?.id; if (!taskId || !input) return; state.set(taskId, { subject: input.subject, status: "pending", activeForm: input.activeForm, description: input.description, }); } export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undefined): void { if (!input?.taskId) return; if (input.status === "deleted") { state.delete(input.taskId); return; } const existing = state.get(input.taskId); // Without a subject from either the existing entry or the update payload, // we'd produce a plan entry with empty `content` — drop the update. const subject = input.subject ?? existing?.subject; if (!subject) return; state.set(input.taskId, { subject, status: input.status ?? existing?.status ?? "pending", activeForm: input.activeForm ?? existing?.activeForm, description: input.description ?? existing?.description, }); } export function taskStateToPlanEntries(state: TaskState): PlanEntry[] { return Array.from(state.values()).map((task) => ({ content: task.subject, status: task.status, priority: "medium", })); } export function markdownEscape(text: string): string { let escape = "```"; for (const [m] of text.matchAll(/^```+/gm)) { while (m.length >= escape.length) { escape += "`"; } } return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape; } interface DiffToolResponseHunk { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[]; } interface DiffToolResponse { filePath?: string; structuredPatch?: DiffToolResponseHunk[]; } /** * Builds diff ToolUpdate content from the structured toolResponse provided by * the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing * the plain unified diff string, this uses the pre-parsed structuredPatch * which supports multiple replacement sites (replaceAll) and always includes * context lines for better readability. */ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): { content?: ToolCallContent[]; locations?: ToolCallLocation[]; } { if (!toolResponse || typeof toolResponse !== "object") return {}; const response = toolResponse as DiffToolResponse; if (!response.filePath || !Array.isArray(response.structuredPatch)) return {}; const content: ToolCallContent[] = []; const locations: ToolCallLocation[] = []; for (const { lines, newStart } of response.structuredPatch) { const oldText: string[] = []; const newText: string[] = []; for (const line of lines) { if (line.startsWith("-")) { oldText.push(line.slice(1)); } else if (line.startsWith("+")) { newText.push(line.slice(1)); } else { oldText.push(line.slice(1)); newText.push(line.slice(1)); } } if (oldText.length > 0 || newText.length > 0) { locations.push({ path: response.filePath, line: newStart }); content.push({ type: "diff", path: response.filePath, oldText: oldText.join("\n") || null, newText: newText.join("\n"), }); } } const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {}; if (content.length > 0) result.content = content; if (locations.length > 0) result.locations = locations; return result; } /* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */ const toolUseCallbacks: { [toolUseId: string]: { onPostToolUseHook?: ( toolUseID: string, toolInput: unknown, toolResponse: unknown, ) => Promise; }; } = {}; /* Setup callbacks that will be called when receiving hooks from Claude Code */ export const registerHookCallback = ( toolUseID: string, { onPostToolUseHook, }: { onPostToolUseHook?: ( toolUseID: string, toolInput: unknown, toolResponse: unknown, ) => Promise; }, ) => { toolUseCallbacks[toolUseID] = { onPostToolUseHook, }; }; /* A callback for Claude Code that is called when receiving a PostToolUse hook */ export const createPostToolUseHook = ( logger: Logger = console, options?: { onEnterPlanMode?: () => Promise; }, ): HookCallback => async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => { if (input.hook_event_name === "PostToolUse") { // Handle EnterPlanMode tool - notify client of mode change after successful execution if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) { await options.onEnterPlanMode(); } if (toolUseID) { const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook; if (onPostToolUseHook) { await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response); delete toolUseCallbacks[toolUseID]; // Cleanup after execution } else { logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`); delete toolUseCallbacks[toolUseID]; } } } return { continue: true }; }; /** * Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires * these for both user-facing TaskCreate tool calls and subagent task * creation, giving us `task_id` + `task_subject` without having to parse * tool_result payloads. * * Populating `taskState` from the hook means a later `TaskUpdate` (which * typically only carries `taskId` + `status`) finds an existing entry with * a real subject, instead of synthesizing a placeholder with empty content. */ export const createTaskHook = (options: { taskState: TaskState; onChange?: () => Promise }): HookCallback => async (input): Promise<{ continue: boolean }> => { const taskId = "task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined; if (!taskId) return { continue: true }; if (input.hook_event_name === "TaskCreated") { if (!input.task_subject) return { continue: true }; if (options.taskState.has(taskId)) return { continue: true }; options.taskState.set(taskId, { subject: input.task_subject, status: "pending", description: input.task_description, }); if (options.onChange) await options.onChange(); } else if (input.hook_event_name === "TaskCompleted") { const existing = options.taskState.get(taskId); if (!existing || existing.status === "completed") return { continue: true }; options.taskState.set(taskId, { ...existing, status: "completed" }); if (options.onChange) await options.onChange(); } return { continue: true }; };