Hook API: replace send() with sendMessage(), add appendEntry() and registerCommand()

Breaking changes to Hook API:
- pi.send(text, attachments?) replaced with pi.sendMessage(message, triggerTurn?)
  - Creates CustomMessageEntry instead of user messages
  - Properly handles queuing during streaming via agent loop
  - Supports optional turn triggering when idle
- New pi.appendEntry(customType, data?) for hook state persistence
- New pi.registerCommand(name, options) for custom slash commands
- Handler types renamed: SendHandler -> SendMessageHandler, new AppendEntryHandler

Implementation:
- AgentSession.sendHookMessage() handles all three cases:
  - Streaming: queues message with _hookData marker, agent loop processes it
  - Not streaming + triggerTurn: appends to state/session, calls agent.continue()
  - Not streaming + no trigger: appends to state/session only
- message_end handler routes based on _hookData presence to correct persistence
- HookRunner gains getRegisteredCommands() and getCommand() methods

New types: HookMessage<T>, RegisteredCommand, CommandContext
This commit is contained in:
Mario Zechner 2025-12-27 00:52:10 +01:00
parent d43a5e47a1
commit ba185b0571
13 changed files with 412 additions and 77 deletions

View file

@ -14,7 +14,7 @@
*/
import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import { getAuthPath } from "../config.js";
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
@ -27,7 +27,7 @@ import {
} from "./compaction.js";
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html.js";
import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js";
import type { BashExecutionMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import type { CompactionEntry, SessionManager } from "./session-manager.js";
@ -101,6 +101,13 @@ export interface SessionStats {
cost: number;
}
/** Internal marker for hook messages queued through the agent loop */
interface HookMessageData {
customType: string;
display: boolean;
details?: unknown;
}
// ============================================================================
// Constants
// ============================================================================
@ -211,7 +218,21 @@ export class AgentSession {
// Handle session persistence
if (event.type === "message_end") {
this.sessionManager.appendMessage(event.message);
// Check if this is a hook message (has _hookData marker)
type HookAppMessage = AppMessage & { _hookData?: HookMessageData; content: (TextContent | ImageContent)[] };
const hookMessage = event.message as HookAppMessage;
if (hookMessage._hookData) {
// Persist as CustomMessageEntry
this.sessionManager.appendCustomMessageEntry(
hookMessage._hookData.customType,
hookMessage.content,
hookMessage._hookData.display,
hookMessage._hookData.details,
);
} else {
// Regular message - persist as SessionMessageEntry
this.sessionManager.appendMessage(event.message);
}
// Track assistant message for auto-compaction (checked on agent_end)
if (event.message.role === "assistant") {
@ -473,6 +494,60 @@ export class AgentSession {
});
}
/**
* Send a hook message to the session. Creates a CustomMessageEntry.
*
* Handles three cases:
* - Streaming: queues message, processed when loop pulls from queue
* - Not streaming + triggerTurn: appends to state/session, starts new turn
* - Not streaming + no trigger: appends to state/session, no turn
*
* @param message Hook message with customType, content, display, details
* @param triggerTurn If true and not streaming, triggers a new LLM turn
*/
async sendHookMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): Promise<void> {
// Normalize content to array format for the AppMessage
const content: (TextContent | ImageContent)[] =
typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content;
// Create AppMessage with _hookData marker for routing in message_end handler
const appMessage: AppMessage & { _hookData: HookMessageData } = {
role: "user",
content,
timestamp: Date.now(),
_hookData: {
customType: message.customType,
display: message.display,
details: message.details,
},
};
if (this.isStreaming) {
// Queue for processing by agent loop
await this.agent.queueMessage(appMessage);
} else if (triggerTurn) {
// Append to agent state and session, then trigger a turn
this.agent.appendMessage(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
);
// Start a new turn - agent.continue() works because last message is user role
await this.agent.continue();
} else {
// Just append to agent state and session, no turn
this.agent.appendMessage(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
);
}
}
/**
* Clear queued messages and return them.
* Useful for restoring to editor when user aborts.

View file

@ -1,4 +1,11 @@
export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js";
export {
type AppendEntryHandler,
discoverAndLoadHooks,
type LoadedHook,
type LoadHooksResult,
loadHooks,
type SendMessageHandler,
} from "./loader.js";
export { type HookErrorListener, HookRunner } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type {
@ -17,9 +24,11 @@ export type {
HookEvent,
HookEventContext,
HookFactory,
HookMessage,
HookUIContext,
LsToolResultEvent,
ReadToolResultEvent,
RegisteredCommand,
SessionEvent,
SessionEventResult,
ToolCallEvent,

View file

@ -7,10 +7,9 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Attachment } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { CustomMessageRenderer, HookAPI, HookFactory } from "./types.js";
import type { CustomMessageRenderer, HookAPI, HookFactory, HookMessage, RegisteredCommand } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -47,9 +46,14 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Send handler type for pi.send().
* Send message handler type for pi.sendMessage().
*/
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
export type SendMessageHandler = <T = unknown>(message: HookMessage<T>, triggerTurn?: boolean) => void;
/**
* Append entry handler type for pi.appendEntry().
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* Registered handlers for a loaded hook.
@ -63,8 +67,12 @@ export interface LoadedHook {
handlers: Map<string, HandlerFn[]>;
/** Map of customType to custom message renderer */
customMessageRenderers: Map<string, CustomMessageRenderer>;
/** Set the send handler for this hook's pi.send() */
setSendHandler: (handler: SendHandler) => void;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** Set the send message handler for this hook's pi.sendMessage() */
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
}
/**
@ -112,18 +120,24 @@ function resolveHookPath(hookPath: string, cwd: string): string {
}
/**
* Create a HookAPI instance that collects handlers and renderers.
* Returns the API, renderers map, and a function to set the send handler later.
* Create a HookAPI instance that collects handlers, renderers, and commands.
* Returns the API, maps, and a function to set the send message handler later.
*/
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
api: HookAPI;
customMessageRenderers: Map<string, CustomMessageRenderer>;
setSendHandler: (handler: SendHandler) => void;
commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
} {
let sendHandler: SendHandler = () => {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
};
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
const customMessageRenderers = new Map<string, CustomMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const api: HookAPI = {
on(event: string, handler: HandlerFn): void {
@ -131,19 +145,29 @@ function createHookAPI(handlers: Map<string, HandlerFn[]>): {
list.push(handler);
handlers.set(event, list);
},
send(text: string, attachments?: Attachment[]): void {
sendHandler(text, attachments);
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
sendMessageHandler(message, triggerTurn);
},
renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void {
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);
},
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void {
customMessageRenderers.set(customType, renderer);
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
},
} as HookAPI;
return {
api,
customMessageRenderers,
setSendHandler: (handler: SendHandler) => {
sendHandler = handler;
commands,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
};
}
@ -172,13 +196,22 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const { api, customMessageRenderers, setSendHandler } = createHookAPI(handlers);
const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } =
createHookAPI(handlers);
// Call factory to register handlers
factory(api);
return {
hook: { path: hookPath, resolvedPath, handlers, customMessageRenderers, setSendHandler },
hook: {
path: hookPath,
resolvedPath,
handlers,
customMessageRenderers,
commands,
setSendMessageHandler,
setAppendEntryHandler,
},
error: null,
};
} catch (err) {

View file

@ -3,7 +3,7 @@
*/
import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type {
CustomMessageRenderer,
ExecOptions,
@ -12,6 +12,7 @@ import type {
HookEvent,
HookEventContext,
HookUIContext,
RegisteredCommand,
SessionEvent,
SessionEventResult,
ToolCallEvent,
@ -164,12 +165,22 @@ export class HookRunner {
}
/**
* Set the send handler for all hooks' pi.send().
* Set the send message handler for all hooks' pi.sendMessage().
* Call this when the mode initializes.
*/
setSendHandler(handler: SendHandler): void {
setSendMessageHandler(handler: SendMessageHandler): void {
for (const hook of this.hooks) {
hook.setSendHandler(handler);
hook.setSendMessageHandler(handler);
}
}
/**
* Set the append entry handler for all hooks' pi.appendEntry().
* Call this when the mode initializes.
*/
setAppendEntryHandler(handler: AppendEntryHandler): void {
for (const hook of this.hooks) {
hook.setAppendEntryHandler(handler);
}
}
@ -218,6 +229,33 @@ export class HookRunner {
return undefined;
}
/**
* Get all registered commands from all hooks.
*/
getRegisteredCommands(): RegisteredCommand[] {
const commands: RegisteredCommand[] = [];
for (const hook of this.hooks) {
for (const command of hook.commands.values()) {
commands.push(command);
}
}
return commands;
}
/**
* Get a registered command by name.
* Returns the first command found across all hooks, or undefined if none.
*/
getCommand(name: string): RegisteredCommand | undefined {
for (const hook of this.hooks) {
const command = hook.commands.get(name);
if (command) {
return command;
}
}
return undefined;
}
/**
* Create the event context for handlers.
*/

View file

@ -5,7 +5,7 @@
* and interact with the user via UI primitives.
*/
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
@ -388,9 +388,50 @@ export type CustomMessageRenderer<T = unknown> = (
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 command handlers.
*/
export interface CommandContext {
/** Arguments after the command name */
args: string;
/** UI methods for user interaction */
ui: HookUIContext;
/** Execute a command and return stdout/stderr/code */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** Whether UI is available (false in print mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Session manager for reading/writing session entries */
sessionManager: SessionManager;
/** Model registry for API keys */
modelRegistry: ModelRegistry;
/**
* Send a custom message to the session.
* If streaming, queued and appended after turn ends.
* If idle and triggerTurn=true, appends and triggers a new turn.
* If idle and triggerTurn=false (default), just appends.
*/
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void;
}
/**
* Command registration options.
*/
export interface RegisteredCommand {
name: string;
description?: string;
handler: (ctx: CommandContext) => Promise<string | undefined>;
}
/**
* HookAPI passed to hook factory functions.
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
*/
export interface HookAPI {
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
@ -403,18 +444,62 @@ export interface HookAPI {
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
/**
* Send a message to the agent.
* If the agent is streaming, the message is queued.
* If the agent is idle, a new agent loop is started.
* Send a custom message to the session. Creates a CustomMessageEntry that
* participates in LLM context and can be displayed in the TUI.
*
* Use this when you want the LLM to see the message content.
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
*
* @param message - The message to send
* @param message.customType - Identifier for your hook (used for filtering on reload)
* @param message.content - Message content (string or TextContent/ImageContent array)
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
* @param message.details - Optional hook-specific metadata (not sent to LLM)
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
* If agent is streaming, message is queued and triggerTurn is ignored.
*/
send(text: string, attachments?: Attachment[]): void;
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void;
/**
* Append a custom entry to the session for hook state persistence.
* Creates a CustomEntry that does NOT participate in LLM context.
*
* Use this to store hook-specific data that should persist across session reloads
* but should NOT be sent to the LLM. On reload, scan session entries for your
* customType to reconstruct hook state.
*
* For messages that SHOULD be sent to the LLM, use sendMessage() instead.
*
* @param customType - Identifier for your hook (used for filtering on reload)
* @param data - Hook-specific data to persist (must be JSON-serializable)
*
* @example
* // Store permission state
* pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() });
*
* // On reload, reconstruct state from entries
* pi.on("session", async (event, ctx) => {
* if (event.reason === "start") {
* const entries = event.sessionManager.getEntries();
* const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions");
* // Reconstruct state from myEntries...
* }
* });
*/
appendEntry<T = unknown>(customType: string, data?: T): void;
/**
* Register a custom renderer for CustomMessageEntry with a specific customType.
* The renderer is called when rendering the entry in the TUI.
* Return null to use the default renderer.
*/
renderCustomMessage<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void;
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void;
/**
* Register a custom slash command.
* Handler receives CommandContext and can return a string to send as prompt.
*/
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
}
/**

View file

@ -341,7 +341,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
return definitions.map((def) => {
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const customMessageRenderers = new Map<string, any>();
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
@ -349,12 +351,18 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
list.push(handler);
handlers.set(event, list);
},
send: (text: string, attachments?: any[]) => {
sendHandler(text, attachments);
sendMessage: (message: any, triggerTurn?: boolean) => {
sendMessageHandler(message, triggerTurn);
},
renderCustomMessage: (customType: string, renderer: any) => {
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
},
registerCustomMessageRenderer: (customType: string, renderer: any) => {
customMessageRenderers.set(customType, renderer);
},
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
};
def.factory(api as any);
@ -364,8 +372,12 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
resolvedPath: def.path ?? "<inline>",
handlers,
customMessageRenderers,
setSendHandler: (handler: (text: string, attachments?: any[]) => void) => {
sendHandler = handler;
commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
};
});