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

@ -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({