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

@ -13,8 +13,8 @@ export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
CustomMessageRenderer,
CustomMessageRenderOptions,
HookMessageRenderer,
HookMessageRenderOptions,
CustomToolResultEvent,
EditToolResultEvent,
ExecOptions,

View file

@ -11,11 +11,11 @@ import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import { execCommand } from "./runner.js";
import type {
CustomMessageRenderer,
ExecOptions,
HookAPI,
HookFactory,
HookMessage,
HookMessageRenderer,
RegisteredCommand,
} from "./types.js";
@ -73,8 +73,8 @@ export interface LoadedHook {
resolvedPath: string;
/** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>;
/** Map of customType to custom message renderer */
customMessageRenderers: Map<string, CustomMessageRenderer>;
/** Map of customType to hook message renderer */
messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** Set the send message handler for this hook's pi.sendMessage() */
@ -136,7 +136,7 @@ function createHookAPI(
cwd: string,
): {
api: HookAPI;
customMessageRenderers: Map<string, CustomMessageRenderer>;
messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
@ -147,7 +147,7 @@ function createHookAPI(
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
const customMessageRenderers = new Map<string, CustomMessageRenderer>();
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
// Cast to HookAPI - the implementation is more general (string event names)
@ -164,8 +164,8 @@ function createHookAPI(
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);
},
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void {
customMessageRenderers.set(customType, renderer as CustomMessageRenderer);
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
messageRenderers.set(customType, renderer as HookMessageRenderer);
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
@ -177,7 +177,7 @@ function createHookAPI(
return {
api,
customMessageRenderers,
messageRenderers,
commands,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
@ -212,7 +212,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
handlers,
cwd,
);
@ -225,7 +225,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
path: hookPath,
resolvedPath,
handlers,
customMessageRenderers,
messageRenderers,
commands,
setSendMessageHandler,
setAppendEntryHandler,

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;
}

View file

@ -10,6 +10,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mario
import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { ModelRegistry } from "../model-registry.js";
import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js";
@ -21,29 +22,8 @@ import type {
ReadToolDetails,
} from "../tools/index.js";
// ============================================================================
// Execution Context
// ============================================================================
/**
* Result of executing a command via ctx.exec()
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/**
* UI context for hooks to request interactive UI from the harness.
@ -372,26 +352,26 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
/**
* Options passed to custom message renderers.
*/
export interface CustomMessageRenderOptions {
/**
* Message type for hooks to send. Creates CustomMessageEntry in the session.
*/
export type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, "customType" | "content" | "display" | "details">;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
expanded: boolean;
}
/**
* Renderer for custom message entries.
* Hooks register these to provide custom TUI rendering for their CustomMessageEntry types.
* Renderer for hook messages.
* Hooks register these to provide custom TUI rendering for their message types.
*/
export type CustomMessageRenderer<T = unknown> = (
entry: CustomMessageEntry<T>,
options: CustomMessageRenderOptions,
export type HookMessageRenderer<T = unknown> = (
message: HookMessage<T>,
options: HookMessageRenderOptions,
theme: Theme,
) => Component | null;
/**
* Message type for hooks to send. Creates CustomMessageEntry in the session.
*/
export type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, "customType" | "content" | "display" | "details">;
/**
* Context passed to hook command handlers.
*/
@ -483,7 +463,7 @@ export interface HookAPI {
* The renderer is called when rendering the entry in the TUI.
* Return null to use the default renderer.
*/
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void;
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
/**
* Register a custom slash command.