/**
* Hooks System — shell commands that run before/after tool calls and at session lifecycle events.
*
* Hooks are configured in:
* - Project: .whale/hooks.json (array of HookConfig)
* - User: ~/.swagmanager/hooks.json (array of HookConfig)
*
* Both are loaded and merged (project hooks run first).
*
* Hook process receives JSON on stdin:
* { event, tool_name?, tool_input?, tool_output?, tool_success?, session_id? }
*
* Hook process may output JSON on stdout:
* { allow?: boolean, message?: string, modified_input?: object, modified_output?: string }
*
* If allow: false, the tool call is blocked and message is returned as the tool result.
* If hook exits non-zero, it is logged as a warning but does not crash the session.
*/
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { spawn } from "child_process";
// ============================================================================
// TYPES
// ============================================================================
export type HookEvent =
| "BeforeTool"
| "AfterTool"
| "SessionStart"
| "SessionEnd"
| "Notification";
export interface HookConfig {
/** Which event triggers this hook */
event: HookEvent;
/** Shell command to run */
command: string;
/** Optional: only trigger for matching tool names (glob pattern) */
pattern?: string;
/** Max ms to wait for hook process (default 10000) */
timeout?: number;
/** Can hook modify the tool input/output? (default false) */
allowModify?: boolean;
}
/** JSON sent to hook process on stdin */
interface HookPayload {
event: HookEvent;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_output?: string;
tool_success?: boolean;
session_id?: string;
}
/** JSON output from hook process on stdout */
interface HookResponse {
allow?: boolean;
message?: string;
modified_input?: Record<string, unknown>;
modified_output?: string;
}
const DEFAULT_TIMEOUT_MS = 10_000;
// ============================================================================
// GLOB MATCHING
// ============================================================================
/**
* Match a tool name against a simple glob pattern.
* Supports * (any chars) and ? (single char).
*/
export function matchGlob(pattern: string, name: string): boolean {
// Escape regex special chars except * and ?
const regex = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${regex}$`).test(name);
}
// ============================================================================
// LOAD HOOKS
// ============================================================================
const VALID_EVENTS: HookEvent[] = [
"BeforeTool",
"AfterTool",
"SessionStart",
"SessionEnd",
"Notification",
];
/**
* Load hooks from project (.whale/hooks.json) and user (~/.swagmanager/hooks.json).
* Project hooks come first in the array, then user hooks.
* Invalid files are skipped with a warning.
*/
export function loadHooks(cwd: string): HookConfig[] {
const hooks: HookConfig[] = [];
// Project hooks (higher priority, run first)
const projectPath = join(cwd, ".whale", "hooks.json");
const projectHooks = loadHooksFromFile(projectPath);
if (projectHooks) hooks.push(...projectHooks);
// User hooks
const userPath = join(homedir(), ".swagmanager", "hooks.json");
const userHooks = loadHooksFromFile(userPath);
if (userHooks) hooks.push(...userHooks);
return hooks;
}
function loadHooksFromFile(filePath: string): HookConfig[] | null {
if (!existsSync(filePath)) return null;
try {
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
console.error(`[hooks] Warning: ${filePath} should be a JSON array, skipping`);
return null;
}
// Validate each hook config
return parsed.filter((h: unknown) => {
if (!h || typeof h !== "object") return false;
const hook = h as Record<string, unknown>;
if (typeof hook.event !== "string" || typeof hook.command !== "string") {
console.error(`[hooks] Warning: Invalid hook in ${filePath}, missing event or command`);
return false;
}
if (!VALID_EVENTS.includes(hook.event as HookEvent)) {
console.error(`[hooks] Warning: Invalid event "${hook.event}" in ${filePath}`);
return false;
}
return true;
}) as HookConfig[];
} catch (err) {
console.error(
`[hooks] Warning: Failed to parse ${filePath}: ${err instanceof Error ? err.message : err}`,
);
return null;
}
}
// ============================================================================
// EXECUTE HOOK
// ============================================================================
/**
* Execute a hook command with JSON payload on stdin.
* Returns parsed JSON response from stdout, or null on error/timeout.
*/
export async function executeHook(
hook: HookConfig,
payload: HookPayload,
): Promise<HookResponse | null> {
const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn("/bin/sh", ["-c", hook.command], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
});
let timer: ReturnType<typeof setTimeout> | undefined;
const settle = (result: HookResponse | null) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve(result);
};
// Kill on timeout
timer = setTimeout(() => {
if (!settled) {
console.error(
`[hooks] Warning: Hook "${hook.command}" timed out after ${timeout}ms, killing`,
);
try {
child.kill("SIGKILL");
} catch {
/* ignore */
}
settle(null);
}
}, timeout);
child.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.on("error", (err) => {
console.error(
`[hooks] Warning: Hook "${hook.command}" failed to start: ${err.message}`,
);
settle(null);
});
child.on("close", (code) => {
if (code !== 0) {
if (stderr) {
console.error(
`[hooks] Warning: Hook "${hook.command}" exited with code ${code}: ${stderr.trim()}`,
);
} else {
console.error(
`[hooks] Warning: Hook "${hook.command}" exited with code ${code}`,
);
}
settle(null);
return;
}
// Parse stdout as JSON
const trimmed = stdout.trim();
if (!trimmed) {
settle(null);
return;
}
try {
settle(JSON.parse(trimmed) as HookResponse);
} catch {
// Non-JSON output is not an error, just no structured response
settle(null);
}
});
// Write payload to stdin
try {
child.stdin?.write(JSON.stringify(payload));
child.stdin?.end();
} catch {
// stdin may already be closed — that is fine
}
});
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Run BeforeTool hooks for a given tool call.
* Returns whether the tool call should proceed and optionally modified input.
*/
export async function runBeforeToolHook(
hooks: HookConfig[],
toolName: string,
input: Record<string, unknown>,
): Promise<{
allow: boolean;
message?: string;
modifiedInput?: Record<string, unknown>;
}> {
const matching = hooks.filter(
(h) =>
h.event === "BeforeTool" &&
(!h.pattern || matchGlob(h.pattern, toolName)),
);
if (matching.length === 0) return { allow: true };
const payload: HookPayload = {
event: "BeforeTool",
tool_name: toolName,
tool_input: input,
};
for (const hook of matching) {
const response = await executeHook(hook, payload);
if (!response) continue;
// If any hook blocks, stop immediately
if (response.allow === false) {
return {
allow: false,
message: response.message || `Blocked by hook: ${hook.command}`,
};
}
// If hook modifies input and is allowed to
if (hook.allowModify && response.modified_input) {
return { allow: true, modifiedInput: response.modified_input };
}
}
return { allow: true };
}
/**
* Run AfterTool hooks for a given tool result.
* Returns optionally modified output.
*/
export async function runAfterToolHook(
hooks: HookConfig[],
toolName: string,
output: string,
success: boolean,
): Promise<{ modifiedOutput?: string }> {
const matching = hooks.filter(
(h) =>
h.event === "AfterTool" &&
(!h.pattern || matchGlob(h.pattern, toolName)),
);
if (matching.length === 0) return {};
const payload: HookPayload = {
event: "AfterTool",
tool_name: toolName,
tool_output: output,
tool_success: success,
};
for (const hook of matching) {
const response = await executeHook(hook, payload);
if (!response) continue;
// If hook modifies output and is allowed to
if (hook.allowModify && response.modified_output !== undefined) {
return { modifiedOutput: response.modified_output };
}
}
return {};
}
/**
* Run session lifecycle hooks (SessionStart, SessionEnd, Notification).
* Fire-and-forget — errors are logged but don't affect session.
*/
export async function runSessionHook(
hooks: HookConfig[],
event: "SessionStart" | "SessionEnd" | "Notification",
data?: Record<string, unknown>,
): Promise<void> {
const matching = hooks.filter((h) => h.event === event);
if (matching.length === 0) return;
const payload: HookPayload = {
event,
...(data || {}),
};
// Run all session hooks concurrently — don't wait or fail
await Promise.allSettled(matching.map((hook) => executeHook(hook, payload)));
}