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:
Mario Zechner 2025-12-09 22:17:12 +01:00
parent 195760d8ee
commit 04d59f31ea
17 changed files with 1264 additions and 126 deletions

View file

@ -0,0 +1,64 @@
/**
* Simple text input component for hooks.
*/
import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookInputComponent extends Container {
private input: Input;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
constructor(
title: string,
_placeholder: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
) {
super();
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.addChild(new Spacer(1));
// Create input
this.input = new Input();
this.addChild(this.input);
this.addChild(new Spacer(1));
// Add hint
this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
// Enter
if (keyData === "\r" || keyData === "\n") {
this.onSubmitCallback(this.input.getValue());
return;
}
// Escape to cancel
if (keyData === "\x1b") {
this.onCancelCallback();
return;
}
// Forward to input
this.input.handleInput(keyData);
}
}

View file

@ -0,0 +1,91 @@
/**
* Generic selector component for hooks.
* Displays a list of string options with keyboard navigation.
*/
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookSelectorComponent extends Container {
private options: string[];
private selectedIndex = 0;
private listContainer: Container;
private onSelectCallback: (option: string) => void;
private onCancelCallback: () => void;
constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) {
super();
this.options = options;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.addChild(new Spacer(1));
// Create list container
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
// Add hint
this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
// Initial render
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.options.length; i++) {
const option = this.options[i];
const isSelected = i === this.selectedIndex;
let text = "";
if (isSelected) {
text = theme.fg("accent", "→ ") + theme.fg("accent", option);
} else {
text = " " + theme.fg("text", option);
}
this.listContainer.addChild(new Text(text, 1, 0));
}
}
handleInput(keyData: string): void {
// Up arrow or k
if (keyData === "\x1b[A" || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
}
// Down arrow or j
else if (keyData === "\x1b[B" || keyData === "j") {
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
this.updateList();
}
// Enter
else if (keyData === "\r" || keyData === "\n") {
const selected = this.options[this.selectedIndex];
if (selected) {
this.onSelectCallback(selected);
}
}
// Escape
else if (keyData === "\x1b") {
this.onCancelCallback();
}
}
}

View file

@ -25,6 +25,7 @@ import {
import { exec } from "child_process";
import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js";
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
import type { HookUIContext } from "../../core/hooks/index.js";
import { isBashExecutionMessage } from "../../core/messages.js";
import { invalidateOAuthCache } from "../../core/model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
@ -38,6 +39,8 @@ import { CompactionComponent } from "./components/compaction.js";
import { CustomEditor } from "./components/custom-editor.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { FooterComponent } from "./components/footer.js";
import { HookInputComponent } from "./components/hook-input.js";
import { HookSelectorComponent } from "./components/hook-selector.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js";
@ -98,6 +101,10 @@ export class InteractiveMode {
private autoCompactionLoader: Loader | null = null;
private autoCompactionEscapeHandler?: () => void;
// Hook UI state
private hookSelector: HookSelectorComponent | null = null;
private hookInput: HookInputComponent | null = null;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -242,6 +249,9 @@ export class InteractiveMode {
this.ui.start();
this.isInitialized = true;
// Initialize hooks with TUI-based UI context
await this.initHooks();
// Subscribe to agent events
this.subscribeToAgent();
@ -258,6 +268,144 @@ export class InteractiveMode {
});
}
// =========================================================================
// Hook System
// =========================================================================
/**
* Initialize the hook system with TUI-based UI context.
*/
private async initHooks(): Promise<void> {
// Create hook UI context
const hookUIContext = this.createHookUIContext();
// Set context on session
this.session.setHookUIContext(hookUIContext, (error) => {
this.showHookError(error.hookPath, error.error);
});
// Initialize hooks and report any loading errors
const loadErrors = await this.session.initHooks();
for (const { path, error } of loadErrors) {
this.showHookError(path, error);
}
}
/**
* Create the UI context for hooks.
*/
private createHookUIContext(): HookUIContext {
return {
select: (title, options) => this.showHookSelector(title, options),
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
};
}
/**
* Show a selector for hooks.
*/
private showHookSelector(title: string, options: string[]): Promise<string | null> {
return new Promise((resolve) => {
this.hookSelector = new HookSelectorComponent(
title,
options,
(option) => {
this.hideHookSelector();
resolve(option);
},
() => {
this.hideHookSelector();
resolve(null);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookSelector);
this.ui.setFocus(this.hookSelector);
this.ui.requestRender();
});
}
/**
* Hide the hook selector.
*/
private hideHookSelector(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookSelector = null;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a confirmation dialog for hooks.
*/
private async showHookConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
return result === "Yes";
}
/**
* Show a text input for hooks.
*/
private showHookInput(title: string, placeholder?: string): Promise<string | null> {
return new Promise((resolve) => {
this.hookInput = new HookInputComponent(
title,
placeholder,
(value) => {
this.hideHookInput();
resolve(value);
},
() => {
this.hideHookInput();
resolve(null);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookInput);
this.ui.setFocus(this.hookInput);
this.ui.requestRender();
});
}
/**
* Hide the hook input.
*/
private hideHookInput(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookInput = null;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a notification for hooks.
*/
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
const color = type === "error" ? "error" : type === "warning" ? "warning" : "dim";
const text = new Text(theme.fg(color, `[Hook] ${message}`), 1, 0);
this.chatContainer.addChild(text);
this.ui.requestRender();
}
/**
* Show a hook error in the UI.
*/
private showHookError(hookPath: string, error: string): void {
const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
this.chatContainer.addChild(errorText);
this.ui.requestRender();
}
// =========================================================================
// Key Handlers
// =========================================================================
private setupKeyHandlers(): void {
this.editor.onEscape = () => {
if (this.loadingAnimation) {
@ -1029,12 +1177,18 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
(entryIndex) => {
const selectedText = this.session.branch(entryIndex);
async (entryIndex) => {
const result = await this.session.branch(entryIndex);
if (result.skipped) {
// Hook requested to skip conversation restore
done();
this.ui.requestRender();
return;
}
this.chatContainer.clear();
this.isFirstUserMessage = true;
this.renderInitialMessages(this.session.state);
this.editor.setText(selectedText);
this.editor.setText(result.selectedText);
done();
this.showStatus("Branched to new session");
},

View file

@ -9,6 +9,28 @@
import type { Attachment } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "../core/agent-session.js";
import type { HookUIContext } from "../core/hooks/index.js";
/**
* Create a no-op hook UI context for print mode.
* Hooks can still run but can't prompt the user interactively.
*/
function createNoOpHookUIContext(): HookUIContext {
return {
async select() {
return null;
},
async confirm() {
return false;
},
async input() {
return null;
},
notify() {
// Silent in print mode
},
};
}
/**
* Run in print (single-shot) mode.
@ -27,6 +49,12 @@ export async function runPrintMode(
initialMessage?: string,
initialAttachments?: Attachment[],
): Promise<void> {
// Initialize hooks with no-op UI context (hooks run but can't prompt)
session.setHookUIContext(createNoOpHookUIContext(), (err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`);
});
await session.initHooks();
if (mode === "json") {
// Output all events as JSON
session.subscribe((event) => {

View file

@ -8,21 +8,24 @@
* - Commands: JSON objects with `type` field, optional `id` for correlation
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
* - Events: AgentSessionEvent objects streamed as they occur
* - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response
*/
import * as crypto from "node:crypto";
import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js";
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
import type { HookUIContext } from "../../core/hooks/index.js";
import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
// Re-export types for consumers
export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
/**
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(session: AgentSession): Promise<never> {
const output = (obj: RpcResponse | object) => {
const output = (obj: RpcResponse | RpcHookUIRequest | object) => {
console.log(JSON.stringify(obj));
};
@ -41,6 +44,89 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return { id, type: "response", command, success: false, error: message };
};
// Pending hook UI requests waiting for response
const pendingHookRequests = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
/**
* Create a hook UI context that uses the RPC protocol.
*/
const createHookUIContext = (): HookUIContext => ({
async select(title: string, options: string[]): Promise<string | null> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(null);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(null);
}
},
reject,
});
output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest);
});
},
async confirm(title: string, message: string): Promise<boolean> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(false);
} else if ("confirmed" in response) {
resolve(response.confirmed);
} else {
resolve(false);
}
},
reject,
});
output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest);
});
},
async input(title: string, placeholder?: string): Promise<string | null> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(null);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(null);
}
},
reject,
});
output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest);
});
},
notify(message: string, type?: "info" | "warning" | "error"): void {
// Fire and forget - no response needed
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "notify",
message,
notifyType: type,
} as RpcHookUIRequest);
},
});
// Set up hooks with RPC-based UI context
const hookUIContext = createHookUIContext();
session.setHookUIContext(hookUIContext, (err) => {
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
});
await session.initHooks();
// Output all agent events as JSON
session.subscribe((event) => {
output(event);
@ -202,8 +288,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
case "branch": {
const text = session.branch(command.entryIndex);
return success(id, "branch", { text });
const result = await session.branch(command.entryIndex);
return success(id, "branch", { text: result.selectedText, skipped: result.skipped });
}
case "get_branch_messages": {
@ -240,7 +326,21 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
rl.on("line", async (line: string) => {
try {
const command = JSON.parse(line) as RpcCommand;
const parsed = JSON.parse(line);
// Handle hook UI responses
if (parsed.type === "hook_ui_response") {
const response = parsed as RpcHookUIResponse;
const pending = pendingHookRequests.get(response.id);
if (pending) {
pendingHookRequests.delete(response.id);
pending.resolve(response);
}
return;
}
// Handle regular commands
const command = parsed as RpcCommand;
const response = await handleCommand(command);
output(response);
} catch (e: any) {

View file

@ -157,6 +157,33 @@ export type RpcResponse =
// Error response (any command can fail)
| { id?: string; type: "response"; command: string; success: false; error: string };
// ============================================================================
// Hook UI Events (stdout)
// ============================================================================
/** Emitted when a hook needs user input */
export type RpcHookUIRequest =
| { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] }
| { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string }
| { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
| {
type: "hook_ui_request";
id: string;
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
};
// ============================================================================
// Hook UI Commands (stdin)
// ============================================================================
/** Response to a hook UI request */
export type RpcHookUIResponse =
| { type: "hook_ui_response"; id: string; value: string }
| { type: "hook_ui_response"; id: string; confirmed: boolean }
| { type: "hook_ui_response"; id: string; cancelled: true };
// ============================================================================
// Helper type for extracting command types
// ============================================================================