feat: add prompt based hooks (#795)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-02-05 17:55:00 -08:00
committed by GitHub
parent bbe02e90e8
commit ee28095ebc
11 changed files with 967 additions and 42 deletions

View File

@@ -4,17 +4,35 @@
import { type ChildProcess, spawn } from "node:child_process";
import { buildShellLaunchers } from "../tools/impl/shellLaunchers";
import { executePromptHook } from "./prompt-executor";
import {
type CommandHookConfig,
type HookCommand,
type HookExecutionResult,
HookExitCode,
type HookInput,
type HookResult,
isCommandHook,
isPromptHook,
} from "./types";
/** Default timeout for hook execution (60 seconds) */
const DEFAULT_TIMEOUT_MS = 60000;
/**
* Get a display identifier for a hook (for logging and feedback)
*/
function getHookIdentifier(hook: HookCommand): string {
if (isCommandHook(hook)) {
return hook.command;
}
if (isPromptHook(hook)) {
// Use first 50 chars of prompt as identifier
return `prompt:${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
}
return "unknown";
}
/**
* Try to spawn a hook command with a specific launcher
* Returns the child process or throws an error
@@ -50,13 +68,45 @@ function trySpawnWithLauncher(
}
/**
* Execute a single hook command with JSON input via stdin
* Uses cross-platform shell launchers with fallback support
* Execute a single hook with JSON input
* Dispatches to appropriate executor based on hook type:
* - "command": executes shell command with JSON via stdin
* - "prompt": sends to LLM for evaluation
*/
export async function executeHookCommand(
hook: HookCommand,
input: HookInput,
workingDirectory: string = process.cwd(),
): Promise<HookResult> {
// Dispatch based on hook type
if (isPromptHook(hook)) {
return executePromptHook(hook, input, workingDirectory);
}
// Default to command hook execution
if (isCommandHook(hook)) {
return executeCommandHook(hook, input, workingDirectory);
}
// Unknown hook type
return {
exitCode: HookExitCode.ERROR,
stdout: "",
stderr: "",
timedOut: false,
durationMs: 0,
error: `Unknown hook type: ${(hook as HookCommand).type}`,
};
}
/**
* Execute a command hook with JSON input via stdin
* Uses cross-platform shell launchers with fallback support
*/
export async function executeCommandHook(
hook: CommandHookConfig,
input: HookInput,
workingDirectory: string = process.cwd(),
): Promise<HookResult> {
const startTime = Date.now();
const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
@@ -307,11 +357,10 @@ export async function executeHooks(
}
// Collect feedback from stderr when hook blocks
// Format: [command]: {stderr} per spec
if (result.exitCode === HookExitCode.BLOCK) {
blocked = true;
if (result.stderr) {
feedback.push(`[${hook.command}]: ${result.stderr}`);
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
}
// Stop processing more hooks after a block
break;
@@ -358,7 +407,7 @@ export async function executeHooksParallel(
const hook = hooks[i];
if (!result || !hook) continue;
// For exit 0, try to parse JSON for additionalContext (matching Claude Code behavior)
// For exit 0, try to parse JSON for additionalContext
if (result.exitCode === HookExitCode.ALLOW && result.stdout?.trim()) {
try {
const json = JSON.parse(result.stdout.trim());
@@ -373,11 +422,11 @@ export async function executeHooksParallel(
}
}
// Format: [command]: {stderr} per spec
// Collect feedback from stderr when hook blocks
if (result.exitCode === HookExitCode.BLOCK) {
blocked = true;
if (result.stderr) {
feedback.push(`[${hook.command}]: ${result.stderr}`);
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
}
}
if (result.exitCode === HookExitCode.ERROR) {