Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import * as os from "node:os";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import { createMcpServer } from "./mcp-server.js";
import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js";
import { HookConfig, transformHookConfigs } from "./hook-config.js";
import {
toolInfoFromToolUse,
planEntries,
Expand Down Expand Up @@ -109,6 +110,12 @@ export type NewSessionMeta = {
* - mcpServers (merged with ACP's mcpServers)
*/
options?: Options;
/**
* Hook configurations that define commands to run for specific tool events.
* These will be transformed into hook callbacks and merged with any hooks
* specified in options.
*/
hookConfigs?: HookConfig[];
};
};

Expand Down Expand Up @@ -664,12 +671,16 @@ export class ClaudeAcpAgent implements Agent {

// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const hookConfigs = (params._meta as NewSessionMeta | undefined)?.claudeCode?.hookConfigs;
const extraArgs = { ...userProvidedOptions?.extraArgs };
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
// Set our own session id if not resuming an existing session.
extraArgs["session-id"] = sessionId;
}

// Transform hook configurations into callbacks
const transformedHooks = hookConfigs ? transformHookConfigs(hookConfigs, this.logger) : null;

const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
Expand All @@ -692,15 +703,17 @@ export class ClaudeAcpAgent implements Agent {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
...userProvidedOptions?.hooks,
// Spread all user-configured hooks from transformedHooks
...transformedHooks,
// Merge with built-in hooks
PreToolUse: [
...(userProvidedOptions?.hooks?.PreToolUse || []),
...(transformedHooks?.PreToolUse || []),
{
hooks: [createPreToolUseHook(settingsManager, this.logger)],
},
],
PostToolUse: [
...(userProvidedOptions?.hooks?.PostToolUse || []),
...(transformedHooks?.PostToolUse || []),
{
hooks: [createPostToolUseHook(this.logger)],
},
Expand Down
167 changes: 167 additions & 0 deletions src/hook-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { HookCallback, HookCallbackMatcher, HookEvent } from "@anthropic-ai/claude-agent-sdk";
import { spawn } from "node:child_process";
import { Logger } from "./acp-agent.js";

/**
* Configuration for a hook that runs a command when triggered
*/
export type HookConfig = {
/** The hook event to listen for */
event: HookEvent;
/**
* Optional matcher string to filter which tools trigger this hook.
* Only relevant for tool-related hooks: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest
*/
matcher?: string;
/** Command to execute */
command: string;
/** Arguments for the command */
args?: string[];
/** Environment variables to pass to the command */
env?: Record<string, string>;
};

/**
* Executes a command with the given arguments and environment
*/
async function executeCommand(
command: string,
args: string[],
env: Record<string, string>,
logger: Logger,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve) => {
const child = spawn(command, args, {
env: { ...process.env, ...env },
shell: true,
});

let stdout = "";
let stderr = "";

child.stdout?.on("data", (data) => {
stdout += data.toString();
});

child.stderr?.on("data", (data) => {
stderr += data.toString();
});

child.on("close", (code) => {
resolve({ stdout, stderr, exitCode: code });
});

child.on("error", (error) => {
logger.error(`Failed to execute command: ${error.message}`);
resolve({ stdout, stderr: error.message, exitCode: -1 });
});
});
}

/**
* Creates a HookCallback from a HookConfig
*/
function createHookCallbackFromConfig(config: HookConfig, logger: Logger): HookCallback {
return async (input: any, toolUseID: string | undefined) => {
// Prepare base environment variables
const env: Record<string, string> = {
...config.env,
CLAUDE_CODE_HOOK_EVENT: input.hook_event_name || "",
CLAUDE_CODE_SESSION_ID: input.session_id || "",
CLAUDE_CODE_TRANSCRIPT_PATH: input.transcript_path || "",
CLAUDE_CODE_CWD: input.cwd || "",
CLAUDE_CODE_PERMISSION_MODE: input.permission_mode || "",
};

// Add tool use ID if available
if (toolUseID) {
env.CLAUDE_CODE_TOOL_USE_ID = toolUseID;
}

// Add hook-specific fields as environment variables
for (const [key, value] of Object.entries(input)) {
if (value !== undefined && key !== "hook_event_name") {
const envKey = `CLAUDE_CODE_${key.toUpperCase()}`;
env[envKey] = typeof value === "string" ? value : JSON.stringify(value);
}
}

// Add tool input fields as nested environment variables (for tool-related hooks)
if (input.tool_input && typeof input.tool_input === "object") {
for (const [key, value] of Object.entries(input.tool_input)) {
const envKey = `CLAUDE_CODE_TOOL_INPUT_${key.toUpperCase()}`;
env[envKey] = typeof value === "string" ? value : JSON.stringify(value);
}
}

// Add tool response fields as nested environment variables (for PostToolUse)
if (input.tool_response && typeof input.tool_response === "object") {
for (const [key, value] of Object.entries(input.tool_response)) {
const envKey = `CLAUDE_CODE_TOOL_RESPONSE_${key.toUpperCase()}`;
env[envKey] = typeof value === "string" ? value : JSON.stringify(value);
}
}

// Execute the command
const description = input.tool_name
? `${config.event} on tool ${input.tool_name}`
: config.event;
logger.log(
`[HookConfig] Executing hook for ${description}: ${config.command} ${(config.args || []).join(" ")}`,
);

const result = await executeCommand(config.command, config.args || [], env, logger);

if (result.exitCode !== 0) {
logger.error(
`[HookConfig] Hook command failed with exit code ${result.exitCode}: ${result.stderr}`,
);
} else if (result.stdout) {
logger.log(`[HookConfig] Hook command output: ${result.stdout}`);
}

// Parse the JSON output to determine the return type
try {
const output = result.stdout.trim();
if (output) {
const parsed = JSON.parse(output);
return parsed;
}
} catch (error) {
logger.error(
`[HookConfig] Failed to parse hook output as JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}

// Default to continue: true if parsing fails or no output
return { continue: true };
};
}

/**
* Transforms an array of HookConfigs into hook callbacks that can be used in Options
*/
export function transformHookConfigs(
configs: HookConfig[],
logger: Logger = console,
): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {};

for (const config of configs) {
const callback = createHookCallbackFromConfig(config, logger);
const matcher: HookCallbackMatcher = {
matcher: config.matcher,
hooks: [callback],
};

// Initialize the array for this event if it doesn't exist
if (!hooks[config.event]) {
hooks[config.event] = [];
}

hooks[config.event]!.push(matcher);
}

return hooks;
}

Loading