mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 22:04:46 +00:00
feat(coding-agent): implement hooks system
- Add hooks infrastructure in core/hooks/ (loader, runner, types)
- HookUIContext interface with mode-specific implementations
- Interactive mode: TUI-based selector/input/confirm dialogs
- RPC mode: JSON protocol for hook UI requests/responses
- Print mode: no-op UI context (hooks run but can't prompt)
- AgentSession.branch() now async, returns { selectedText, skipped }
- Settings: hooks[] and hookTimeout configuration
- Export hook types from package for hook authors
Based on PR #147 proposal, adapted for new architecture.
This commit is contained in:
parent
195760d8ee
commit
04d59f31ea
17 changed files with 1264 additions and 126 deletions
17
packages/coding-agent/src/core/hooks/index.ts
Normal file
17
packages/coding-agent/src/core/hooks/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export { type LoadedHook, type LoadHooksResult, loadHooks } from "./loader.js";
|
||||
export { type HookErrorListener, HookRunner } from "./runner.js";
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BranchEvent,
|
||||
BranchEventResult,
|
||||
ExecResult,
|
||||
HookAPI,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
} from "./types.js";
|
||||
138
packages/coding-agent/src/core/hooks/loader.ts
Normal file
138
packages/coding-agent/src/core/hooks/loader.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Hook loader - loads TypeScript hook modules using jiti.
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
import type { HookAPI, HookFactory } from "./types.js";
|
||||
|
||||
/**
|
||||
* Generic handler function type.
|
||||
*/
|
||||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Registered handlers for a loaded hook.
|
||||
*/
|
||||
export interface LoadedHook {
|
||||
/** Original path from config */
|
||||
path: string;
|
||||
/** Resolved absolute path */
|
||||
resolvedPath: string;
|
||||
/** Map of event type to handler functions */
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading hooks.
|
||||
*/
|
||||
export interface LoadHooksResult {
|
||||
/** Successfully loaded hooks */
|
||||
hooks: LoadedHook[];
|
||||
/** Errors encountered during loading */
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand path with ~ support.
|
||||
*/
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith("~/")) {
|
||||
return path.join(os.homedir(), p.slice(2));
|
||||
}
|
||||
if (p.startsWith("~")) {
|
||||
return path.join(os.homedir(), p.slice(1));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve hook path.
|
||||
* - Absolute paths used as-is
|
||||
* - Paths starting with ~ expanded to home directory
|
||||
* - Relative paths resolved from cwd
|
||||
*/
|
||||
function resolveHookPath(hookPath: string, cwd: string): string {
|
||||
const expanded = expandPath(hookPath);
|
||||
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Relative paths resolved from cwd
|
||||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a HookAPI instance that collects handlers.
|
||||
*/
|
||||
function createHookAPI(handlers: Map<string, HandlerFn[]>): HookAPI {
|
||||
return {
|
||||
on(event: string, handler: HandlerFn): void {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
} as HookAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single hook module using jiti.
|
||||
*/
|
||||
async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> {
|
||||
const resolvedPath = resolveHookPath(hookPath, cwd);
|
||||
|
||||
try {
|
||||
// Create jiti instance for TypeScript/ESM loading
|
||||
const jiti = createJiti(import.meta.url);
|
||||
|
||||
// Import the module
|
||||
const module = await jiti.import(resolvedPath, { default: true });
|
||||
const factory = module as HookFactory;
|
||||
|
||||
if (typeof factory !== "function") {
|
||||
return { hook: null, error: "Hook must export a default function" };
|
||||
}
|
||||
|
||||
// Create handlers map and API
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const api = createHookAPI(handlers);
|
||||
|
||||
// Call factory to register handlers
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
hook: { path: hookPath, resolvedPath, handlers },
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { hook: null, error: `Failed to load hook: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all hooks from configuration.
|
||||
* @param paths - Array of hook file paths
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
*/
|
||||
export async function loadHooks(paths: string[], cwd: string): Promise<LoadHooksResult> {
|
||||
const hooks: LoadedHook[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
|
||||
for (const hookPath of paths) {
|
||||
const { hook, error } = await loadHook(hookPath, cwd);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: hookPath, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hook) {
|
||||
hooks.push(hook);
|
||||
}
|
||||
}
|
||||
|
||||
return { hooks, errors };
|
||||
}
|
||||
157
packages/coding-agent/src/core/hooks/runner.ts
Normal file
157
packages/coding-agent/src/core/hooks/runner.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Hook runner - executes hooks and manages their lifecycle.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { LoadedHook } from "./loader.js";
|
||||
import type { BranchEventResult, ExecResult, HookError, HookEvent, HookEventContext, HookUIContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Default timeout for hook execution (30 seconds).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* Listener for hook errors.
|
||||
*/
|
||||
export type HookErrorListener = (error: HookError) => void;
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr/code.
|
||||
*/
|
||||
async function exec(command: string, args: string[], cwd: string): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, { cwd, shell: false });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ stdout, stderr, code: code ?? 0 });
|
||||
});
|
||||
|
||||
proc.on("error", (_err) => {
|
||||
resolve({ stdout, stderr, code: 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promise that rejects after a timeout.
|
||||
*/
|
||||
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
clear: () => clearTimeout(timeoutId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HookRunner executes hooks and manages event emission.
|
||||
*/
|
||||
export class HookRunner {
|
||||
private hooks: LoadedHook[];
|
||||
private uiContext: HookUIContext;
|
||||
private cwd: string;
|
||||
private timeout: number;
|
||||
private errorListeners: Set<HookErrorListener> = new Set();
|
||||
|
||||
constructor(hooks: LoadedHook[], uiContext: HookUIContext, cwd: string, timeout: number = DEFAULT_TIMEOUT) {
|
||||
this.hooks = hooks;
|
||||
this.uiContext = uiContext;
|
||||
this.cwd = cwd;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to hook errors.
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
onError(listener: HookErrorListener): () => void {
|
||||
this.errorListeners.add(listener);
|
||||
return () => this.errorListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an error to all listeners.
|
||||
*/
|
||||
private emitError(error: HookError): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any hooks have handlers for the given event type.
|
||||
*/
|
||||
hasHandlers(eventType: string): boolean {
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(eventType);
|
||||
if (handlers && handlers.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the event context for handlers.
|
||||
*/
|
||||
private createContext(): HookEventContext {
|
||||
return {
|
||||
exec: (command: string, args: string[]) => exec(command, args, this.cwd),
|
||||
ui: this.uiContext,
|
||||
cwd: this.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all hooks.
|
||||
* Returns the result from branch events (if any handler returns one).
|
||||
*/
|
||||
async emit(event: HookEvent): Promise<BranchEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: BranchEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(event.type);
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const timeout = createTimeout(this.timeout);
|
||||
|
||||
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
||||
|
||||
timeout.clear();
|
||||
|
||||
// For branch events, capture the result
|
||||
if (event.type === "branch" && handlerResult) {
|
||||
result = handlerResult as BranchEventResult;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
event: event.type,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
172
packages/coding-agent/src/core/hooks/types.ts
Normal file
172
packages/coding-agent/src/core/hooks/types.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Hook system types.
|
||||
*
|
||||
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
|
||||
* and interact with the user via UI primitives.
|
||||
*/
|
||||
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionEntry } from "../session-manager.js";
|
||||
|
||||
// ============================================================================
|
||||
// Execution Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of executing a command via ctx.exec()
|
||||
*/
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI context for hooks to request interactive UI from the harness.
|
||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
*/
|
||||
export interface HookUIContext {
|
||||
/**
|
||||
* Show a selector and return the user's choice.
|
||||
* @param title - Title to display
|
||||
* @param options - Array of string options
|
||||
* @returns Selected option string, or null if cancelled
|
||||
*/
|
||||
select(title: string, options: string[]): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog.
|
||||
* @returns true if confirmed, false if cancelled
|
||||
*/
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Show a text input dialog.
|
||||
* @returns User input, or null if cancelled
|
||||
*/
|
||||
input(title: string, placeholder?: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Show a notification to the user.
|
||||
*/
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to hook event handlers.
|
||||
*/
|
||||
export interface HookEventContext {
|
||||
/** Execute a command and return stdout/stderr/code */
|
||||
exec(command: string, args: string[]): Promise<ExecResult>;
|
||||
/** UI methods for user interaction */
|
||||
ui: HookUIContext;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Event data for agent_start event.
|
||||
*/
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for agent_end event.
|
||||
*/
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
messages: AppMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for turn_start event.
|
||||
*/
|
||||
export interface TurnStartEvent {
|
||||
type: "turn_start";
|
||||
turnIndex: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for turn_end event.
|
||||
*/
|
||||
export interface TurnEndEvent {
|
||||
type: "turn_end";
|
||||
turnIndex: number;
|
||||
message: AppMessage;
|
||||
toolResults: AppMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for branch event.
|
||||
*/
|
||||
export interface BranchEvent {
|
||||
type: "branch";
|
||||
/** Index of the turn to branch from */
|
||||
targetTurnIndex: number;
|
||||
/** Full session history */
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all hook event types.
|
||||
*/
|
||||
export type HookEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | BranchEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Event Results
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Return type for branch event handlers.
|
||||
* Allows hooks to control branch behavior.
|
||||
*/
|
||||
export interface BranchEventResult {
|
||||
/** If true, skip restoring the conversation (only restore code) */
|
||||
skipConversationRestore?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handler function type for each event.
|
||||
*/
|
||||
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
|
||||
|
||||
/**
|
||||
* HookAPI passed to hook factory functions.
|
||||
* Hooks use pi.on() to subscribe to events.
|
||||
*/
|
||||
export interface HookAPI {
|
||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||
on(event: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook factory function type.
|
||||
* Hooks export a default function that receives the HookAPI.
|
||||
*/
|
||||
export type HookFactory = (pi: HookAPI) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Errors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Error emitted when a hook fails.
|
||||
*/
|
||||
export interface HookError {
|
||||
hookPath: string;
|
||||
event: string;
|
||||
error: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue