mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 20:03:33 +00:00
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:
parent
d43a5e47a1
commit
ba185b0571
13 changed files with 412 additions and 77 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue