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

@ -28,9 +28,14 @@
- Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`)
- Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction)
- **Hook API**:
- New `pi.renderCustomMessage(customType, renderer)` to register custom renderers for `CustomMessageEntry`
- `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages
- New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context)
- New `pi.registerCommand(name, options)` to register custom slash commands
- New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry`
- `CustomMessageRenderer` type: `(entry, options, theme) => Component | null`
- Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `CommandContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
- **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens
@ -46,6 +51,7 @@
- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry<T>` for hook state persistence, `CustomMessageEntry<T>` for hook-injected context messages, `LabelEntry` for user-defined bookmarks
- **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export.
- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden.
- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering.
### Fixed

View file

@ -122,10 +122,67 @@ Behavior:
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
- [x] `buildSessionContext()` includes custom_message entries as user messages
- [x] Exported from main index
- [ ] TUI rendering:
- [x] TUI rendering:
- `display: false` - hidden entirely
- `display: true` - baseline renderer (content with different bg/fg color)
- Custom renderer defined by the hook that contributes it (future)
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
- [x] Renderer returns inner Component, TUI wraps in styled Box
### Hook API Changes
**Renamed:**
- `renderCustomMessage()``registerCustomMessageRenderer()`
**New: `sendMessage()`**
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
```typescript
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
```
Behavior:
- If streaming → queue, append after turn ends (never triggers turn)
- If idle AND `triggerTurn: true` → append and trigger turn
- If idle AND `triggerTurn: false` (default) → just append, no turn
- TUI updates if `display: true`
For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly.
**New: `registerCommand()`**
```typescript
interface CommandContext {
args: string; // Everything after /commandname
session: LimitedAgentSession; // No prompt(), use sendMessage()
ui: HookUIContext;
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}
registerCommand(name: string, options: {
description?: string;
handler: (ctx: CommandContext) => Promise<string | void>;
}): void;
```
Handler return:
- `void` - command completed
- `string` - text to send as prompt (like file-based slash commands)
**New: `ui.custom()`**
For arbitrary hook UI with keyboard focus:
```typescript
interface HookUIContext {
// ... existing: select, confirm, input, notify
/** Show custom component with keyboard focus. Call done() when finished. */
custom(component: Component, done: () => void): void;
}
```
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.

View file

@ -21,7 +21,14 @@ export default function (pi: HookAPI) {
try {
const content = fs.readFileSync(triggerFile, "utf-8").trim();
if (content) {
pi.send(`External trigger: ${content}`);
pi.sendMessage(
{
customType: "file-trigger",
content: `External trigger: ${content}`,
display: true,
},
true, // triggerTurn - get LLM to respond
);
fs.writeFileSync(triggerFile, ""); // Clear after reading
}
} catch {

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

View file

@ -6,7 +6,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
@ -370,9 +370,24 @@ export class InteractiveMode {
this.showHookError(error.hookPath, error.error);
});
// Set up send handler for pi.send()
hookRunner.setSendHandler((text, attachments) => {
this.handleHookSend(text, attachments);
// Set up handlers for pi.sendMessage() and pi.appendEntry()
hookRunner.setSendMessageHandler((message, triggerTurn) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendHookMessage(message, triggerTurn)
.then(() => {
// For non-streaming cases with display=true, update UI
// (streaming cases update via message_end event)
if (!wasStreaming && message.display) {
this.rebuildChatFromMessages();
}
})
.catch((err) => {
this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
});
hookRunner.setAppendEntryHandler((customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
});
// Show loaded hooks
@ -534,19 +549,6 @@ export class InteractiveMode {
* Handle pi.send() from hooks.
* If streaming, queue the message. Otherwise, start a new agent loop.
*/
private handleHookSend(text: string, attachments?: Attachment[]): void {
if (this.session.isStreaming) {
// Queue the message for later (note: attachments are lost when queuing)
this.session.queueMessage(text);
this.updatePendingMessagesDisplay();
} else {
// Start a new agent loop immediately
this.session.prompt(text, { attachments }).catch((err) => {
this.showError(err instanceof Error ? err.message : String(err));
});
}
}
// =========================================================================
// Key Handlers
// =========================================================================

View file

@ -39,9 +39,14 @@ export async function runPrintMode(
hookRunner.onError((err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`);
});
// No-op send handler for print mode (single-shot, no async messages)
hookRunner.setSendHandler(() => {
console.error("Warning: pi.send() is not supported in print mode");
// Set up handlers - sendHookMessage handles queuing/direct append as needed
hookRunner.setSendMessageHandler((message, triggerTurn) => {
session.sendHookMessage(message, triggerTurn).catch((e) => {
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
});
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
// Emit session event
await hookRunner.emit({

View file

@ -131,16 +131,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
hookRunner.onError((err) => {
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
});
// Set up send handler for pi.send()
hookRunner.setSendHandler((text, attachments) => {
// In RPC mode, just queue or prompt based on streaming state
if (session.isStreaming) {
session.queueMessage(text);
} else {
session.prompt(text, { attachments }).catch((e) => {
output(error(undefined, "hook_send", e.message));
});
}
// Set up handlers for pi.sendMessage() and pi.appendEntry()
hookRunner.setSendMessageHandler((message, triggerTurn) => {
session.sendHookMessage(message, triggerTurn).catch((e) => {
output(error(undefined, "hook_send", e.message));
});
});
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
// Emit session event
await hookRunner.emit({

View file

@ -64,7 +64,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
resolvedPath: "/test/test-hook.ts",
handlers,
customMessageRenderers: new Map(),
setSendHandler: () => {},
commands: new Map(),
setSendMessageHandler: () => {},
setAppendEntryHandler: () => {},
};
}
@ -240,7 +242,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
],
]),
customMessageRenderers: new Map(),
setSendHandler: () => {},
commands: new Map(),
setSendMessageHandler: () => {},
setAppendEntryHandler: () => {},
};
createSession([throwingHook]);
@ -284,7 +288,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
],
]),
customMessageRenderers: new Map(),
setSendHandler: () => {},
commands: new Map(),
setSendMessageHandler: () => {},
setAppendEntryHandler: () => {},
};
const hook2: LoadedHook = {
@ -307,7 +313,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
],
]),
customMessageRenderers: new Map(),
setSendHandler: () => {},
commands: new Map(),
setSendMessageHandler: () => {},
setAppendEntryHandler: () => {},
};
createSession([hook1, hook2]);