Add hooks system with pi.send() for external message injection

- Hook discovery from ~/.pi/agent/hooks/, .pi/hooks/, --hook flag
- Events: session_start, session_switch, agent_start/end, turn_start/end, tool_call, tool_result, branch
- tool_call can block execution, tool_result can modify results
- pi.send(text, attachments?) to inject messages from external sources
- UI primitives: ctx.ui.select/confirm/input/notify
- Context: ctx.exec(), ctx.cwd, ctx.sessionFile, ctx.hasUI
- Docs shipped with npm package and binary builds
- System prompt references docs folder
This commit is contained in:
Mario Zechner 2025-12-10 00:50:30 +01:00
parent 942d8d3c95
commit 7c553acd1e
21 changed files with 1307 additions and 83 deletions

View file

@ -5,7 +5,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core";
import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
@ -30,6 +30,7 @@ import { isBashExecutionMessage } from "../../core/messages.js";
import { invalidateOAuthCache } from "../../core/model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
@ -276,24 +277,43 @@ export class InteractiveMode {
* Initialize the hook system with TUI-based UI context.
*/
private async initHooks(): Promise<void> {
const hookPaths = this.settingsManager.getHookPaths();
if (hookPaths.length === 0) {
return; // No hooks configured
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Create hook UI context
const hookUIContext = this.createHookUIContext();
const hookRunner = this.session.hookRunner;
if (!hookRunner) {
return; // No hooks loaded
}
// Set context on session
this.session.setHookUIContext(hookUIContext, (error) => {
// Set TUI-based UI context on the hook runner
hookRunner.setUIContext(this.createHookUIContext(), true);
hookRunner.setSessionFile(this.session.sessionFile);
// Subscribe to hook errors
hookRunner.onError((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);
// Set up send handler for pi.send()
hookRunner.setSendHandler((text, attachments) => {
this.handleHookSend(text, attachments);
});
// Show loaded hooks
const hookPaths = hookRunner.getHookPaths();
if (hookPaths.length > 0) {
const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
}
/**
@ -392,10 +412,13 @@ export class InteractiveMode {
* 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();
if (type === "error") {
this.showError(message);
} else if (type === "warning") {
this.showWarning(message);
} else {
this.showStatus(message);
}
}
/**
@ -407,6 +430,23 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* 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

@ -9,28 +9,6 @@
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.
@ -49,11 +27,21 @@ 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();
// Hook runner already has no-op UI context by default (set in main.ts)
// Set up hooks for print mode (no UI, ephemeral session)
const hookRunner = session.hookRunner;
if (hookRunner) {
hookRunner.setSessionFile(null); // Print mode is ephemeral
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");
});
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
}
if (mode === "json") {
// Output all events as JSON

View file

@ -121,11 +121,27 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
});
// 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();
const hookRunner = session.hookRunner;
if (hookRunner) {
hookRunner.setUIContext(createHookUIContext(), false);
hookRunner.setSessionFile(session.sessionFile);
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));
});
}
});
// Emit session_start event
await hookRunner.emit({ type: "session_start" });
}
// Output all agent events as JSON
session.subscribe((event) => {