Refactor: shared exec utility, rename CustomMessageRenderer to HookMessageRenderer

- Extract execCommand to src/core/exec.ts, shared by hooks and custom-tools
- Rename CustomMessageRenderer -> HookMessageRenderer
- Rename registerCustomMessageRenderer -> registerMessageRenderer
- Renderer now receives HookMessage instead of CustomMessageEntry
- CustomMessageComponent now has setExpanded() and responds to Ctrl+E toggle
- Re-export ExecOptions/ExecResult from exec.ts for backward compatibility
This commit is contained in:
Mario Zechner 2025-12-27 03:10:08 +01:00
parent 5fee9005b7
commit a8866d7a83
12 changed files with 199 additions and 261 deletions

View file

@ -2,17 +2,14 @@
* Hook runner - executes hooks and manages their lifecycle.
*/
import { spawn } from "node:child_process";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type {
CustomMessageRenderer,
ExecOptions,
ExecResult,
HookError,
HookEvent,
HookEventContext,
HookMessageRenderer,
HookUIContext,
RegisteredCommand,
SessionEvent,
@ -32,78 +29,8 @@ const DEFAULT_TIMEOUT = 30000;
*/
export type HookErrorListener = (error: HookError) => void;
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
export async function execCommand(
command: string,
args: string[],
cwd: string,
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/**
* Create a promise that rejects after a timeout.
@ -241,12 +168,12 @@ export class HookRunner {
}
/**
* Get a custom message renderer for the given customType.
* Get a message renderer for the given customType.
* Returns the first renderer found across all hooks, or undefined if none.
*/
getCustomMessageRenderer(customType: string): CustomMessageRenderer | undefined {
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
for (const hook of this.hooks) {
const renderer = hook.customMessageRenderers.get(customType);
const renderer = hook.messageRenderers.get(customType);
if (renderer) {
return renderer;
}