mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Given a number of recent issues stemming from Claude Code using more subagents that don't play nicely with our custom mcp server, plus some slowly diverging behavior, we made the decision to just switch back over to the built-in tools and do more processing on the tool input/output There are some downsides, but also a lot of upsides, on the whole, it feels like a win for now. Closes #310, #279, #305, #236, #94 --------- Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
774 lines
21 KiB
TypeScript
774 lines
21 KiB
TypeScript
import {
|
|
ContentBlock,
|
|
PlanEntry,
|
|
ToolCallContent,
|
|
ToolCallLocation,
|
|
ToolKind,
|
|
} from "@agentclientprotocol/sdk";
|
|
import * as diff from "diff";
|
|
import {
|
|
ImageBlockParam,
|
|
TextBlockParam,
|
|
ToolResultBlockParam,
|
|
WebSearchResultBlock,
|
|
WebSearchToolResultBlockParam,
|
|
WebSearchToolResultError,
|
|
} from "@anthropic-ai/sdk/resources";
|
|
import {
|
|
BetaBashCodeExecutionToolResultBlockParam,
|
|
BetaBashCodeExecutionResultBlock,
|
|
BetaBashCodeExecutionToolResultError,
|
|
BetaCodeExecutionToolResultBlockParam,
|
|
BetaCodeExecutionResultBlock,
|
|
BetaCodeExecutionToolResultError,
|
|
BetaRequestMCPToolResultBlockParam,
|
|
BetaTextEditorCodeExecutionToolResultBlockParam,
|
|
BetaTextEditorCodeExecutionViewResultBlock,
|
|
BetaTextEditorCodeExecutionCreateResultBlock,
|
|
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
|
BetaTextEditorCodeExecutionToolResultError,
|
|
BetaToolResultBlockParam,
|
|
BetaToolSearchToolResultBlockParam,
|
|
BetaToolReferenceBlock,
|
|
BetaToolSearchToolSearchResultBlock,
|
|
BetaToolSearchToolResultError,
|
|
BetaWebFetchToolResultBlockParam,
|
|
BetaWebFetchBlock,
|
|
BetaWebFetchToolResultErrorBlock,
|
|
BetaWebSearchToolResultBlockParam,
|
|
BetaImageBlockParam,
|
|
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
|
|
|
/**
|
|
* 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;
|
|
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
import { Logger } from "./acp-agent.js";
|
|
import {
|
|
AgentInput,
|
|
BashInput,
|
|
FileEditInput,
|
|
FileReadInput,
|
|
FileWriteInput,
|
|
GlobInput,
|
|
GrepInput,
|
|
TodoWriteInput,
|
|
WebFetchInput,
|
|
WebSearchInput,
|
|
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
|
|
|
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;
|
|
};
|
|
};
|
|
}
|
|
|
|
export function toolInfoFromToolUse(
|
|
toolUse: any,
|
|
supportsTerminalOutput: boolean = false,
|
|
): ToolInfo {
|
|
const name = toolUse.name;
|
|
|
|
switch (name) {
|
|
case "Task": {
|
|
const input = toolUse.input as AgentInput | BashInput;
|
|
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;
|
|
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;
|
|
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 + ")";
|
|
}
|
|
return {
|
|
title: "Read " + (input.file_path ?? "File") + limit,
|
|
kind: "read",
|
|
locations: input.file_path
|
|
? [
|
|
{
|
|
path: input.file_path,
|
|
line: input.offset ?? 1,
|
|
},
|
|
]
|
|
: [],
|
|
content: [],
|
|
};
|
|
}
|
|
|
|
case "Write": {
|
|
const input = toolUse.input as FileWriteInput;
|
|
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 },
|
|
},
|
|
];
|
|
}
|
|
return {
|
|
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
|
kind: "edit",
|
|
content,
|
|
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
|
};
|
|
}
|
|
|
|
case "Edit": {
|
|
const input = toolUse.input as FileEditInput;
|
|
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 ?? "",
|
|
},
|
|
];
|
|
}
|
|
return {
|
|
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
|
kind: "edit",
|
|
content,
|
|
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
|
};
|
|
}
|
|
|
|
case "Glob": {
|
|
const input = toolUse.input as GlobInput;
|
|
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;
|
|
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;
|
|
let label = `"${input.query}"`;
|
|
|
|
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;
|
|
return {
|
|
title: Array.isArray(input?.todos)
|
|
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
|
: "Update TODOs",
|
|
kind: "think",
|
|
content: [],
|
|
};
|
|
}
|
|
|
|
case "ExitPlanMode": {
|
|
return {
|
|
title: "Ready to code?",
|
|
kind: "switch_mode",
|
|
content: [],
|
|
};
|
|
}
|
|
|
|
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 "Edit": {
|
|
const content: ToolCallContent[] = [];
|
|
const locations: ToolCallLocation[] = [];
|
|
|
|
if (
|
|
Array.isArray(toolResult.content) &&
|
|
toolResult.content.length > 0 &&
|
|
"text" in toolResult.content[0] &&
|
|
typeof toolResult.content[0].text === "string"
|
|
) {
|
|
const patches = diff.parsePatch(toolResult.content[0].text);
|
|
for (const { oldFileName, newFileName, hunks } of patches) {
|
|
for (const { lines, newStart } of hunks) {
|
|
const oldText = [];
|
|
const newText = [];
|
|
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: newFileName || oldFileName, line: newStart });
|
|
content.push({
|
|
type: "diff",
|
|
path: newFileName || oldFileName,
|
|
oldText: oldText.join("\n") || null,
|
|
newText: newText.join("\n"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const result: ToolUpdate = {};
|
|
if (content.length > 0) {
|
|
result.content = content;
|
|
}
|
|
if (locations.length > 0) {
|
|
result.locations = locations;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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 || "";
|
|
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: output.trim()
|
|
? [
|
|
{
|
|
type: "content",
|
|
content: {
|
|
type: "text",
|
|
text: `\`\`\`sh\n${output.trimEnd()}\n\`\`\``,
|
|
},
|
|
},
|
|
]
|
|
: [],
|
|
_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: `\`\`\`sh\n${output.trimEnd()}\n\`\`\``,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
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[] }): PlanEntry[] {
|
|
return input.todos.map((input) => ({
|
|
content: input.content,
|
|
status: input.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;
|
|
}
|
|
|
|
/* 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<void>;
|
|
};
|
|
} = {};
|
|
|
|
/* 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<void>;
|
|
},
|
|
) => {
|
|
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<void>;
|
|
},
|
|
): 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 };
|
|
};
|