Merge hooks and custom-tools into unified extensions system (#454)

Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
This commit is contained in:
Mario Zechner 2026-01-05 01:43:35 +01:00
parent 9794868b38
commit c6fc084534
112 changed files with 2842 additions and 6747 deletions

View file

@ -34,10 +34,9 @@ import {
prepareCompaction,
shouldCompact,
} from "./compaction/index.js";
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html/index.js";
import type {
HookRunner,
ExtensionRunner,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
@ -45,12 +44,12 @@ import type {
TreePreparation,
TurnEndEvent,
TurnStartEvent,
} from "./hooks/index.js";
import type { BashExecutionMessage, HookMessage } from "./messages.js";
} from "./extensions/index.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
@ -73,16 +72,14 @@ export interface AgentSessionConfig {
settingsManager: SettingsManager;
/** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** File-based slash commands for expansion */
fileCommands?: FileSlashCommand[];
/** Hook runner (created in main.ts with wrapped tools) */
hookRunner?: HookRunner;
/** Custom tools for session lifecycle events */
customTools?: LoadedCustomTool[];
/** File-based prompt templates for expansion */
promptTemplates?: PromptTemplate[];
/** Extension runner (created in sdk.ts with wrapped tools) */
extensionRunner?: ExtensionRunner;
skillsSettings?: Required<SkillsSettings>;
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Tool registry for hook getTools/setTools - maps name to tool */
/** Tool registry for extension getTools/setTools - maps name to tool */
toolRegistry?: Map<string, AgentTool>;
/** Function to rebuild system prompt when tools change */
rebuildSystemPrompt?: (toolNames: string[]) => string;
@ -90,8 +87,8 @@ export interface AgentSessionConfig {
/** Options for AgentSession.prompt() */
export interface PromptOptions {
/** Whether to expand file-based slash commands (default: true) */
expandSlashCommands?: boolean;
/** Whether to expand file-based prompt templates (default: true) */
expandPromptTemplates?: boolean;
/** Image attachments */
images?: ImageContent[];
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */
@ -145,7 +142,7 @@ export class AgentSession {
readonly settingsManager: SettingsManager;
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
private _fileCommands: FileSlashCommand[];
private _promptTemplates: PromptTemplate[];
// Event subscription state
private _unsubscribeAgent?: () => void;
@ -156,7 +153,7 @@ export class AgentSession {
/** Tracks pending follow-up messages for UI display. Removed when delivered. */
private _followUpMessages: string[] = [];
/** Messages queued to be included with the next user prompt as context ("asides"). */
private _pendingNextTurnMessages: HookMessage[] = [];
private _pendingNextTurnMessages: CustomMessage[] = [];
// Compaction state
private _compactionAbortController: AbortController | undefined = undefined;
@ -175,25 +172,22 @@ export class AgentSession {
private _bashAbortController: AbortController | undefined = undefined;
private _pendingBashMessages: BashExecutionMessage[] = [];
// Hook system
private _hookRunner: HookRunner | undefined = undefined;
// Extension system
private _extensionRunner: ExtensionRunner | undefined = undefined;
private _turnIndex = 0;
// Custom tools for session lifecycle
private _customTools: LoadedCustomTool[] = [];
private _skillsSettings: Required<SkillsSettings> | undefined;
// Model registry for API key resolution
private _modelRegistry: ModelRegistry;
// Tool registry for hook getTools/setTools
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool>;
// Function to rebuild system prompt when tools change
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
// Base system prompt (without hook appends) - used to apply fresh appends each turn
// Base system prompt (without extension appends) - used to apply fresh appends each turn
private _baseSystemPrompt: string;
constructor(config: AgentSessionConfig) {
@ -201,9 +195,8 @@ export class AgentSession {
this.sessionManager = config.sessionManager;
this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? [];
this._fileCommands = config.fileCommands ?? [];
this._hookRunner = config.hookRunner;
this._customTools = config.customTools ?? [];
this._promptTemplates = config.promptTemplates ?? [];
this._extensionRunner = config.extensionRunner;
this._skillsSettings = config.skillsSettings;
this._modelRegistry = config.modelRegistry;
this._toolRegistry = config.toolRegistry ?? new Map();
@ -211,7 +204,7 @@ export class AgentSession {
this._baseSystemPrompt = config.agent.state.systemPrompt;
// Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic)
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
}
@ -255,16 +248,16 @@ export class AgentSession {
}
}
// Emit to hooks first
await this._emitHookEvent(event);
// Emit to extensions first
await this._emitExtensionEvent(event);
// Notify all listeners
this._emit(event);
// Handle session persistence
if (event.type === "message_end") {
// Check if this is a hook message
if (event.message.role === "hookMessage") {
// Check if this is a custom message from extensions
if (event.message.role === "custom") {
// Persist as CustomMessageEntry
this.sessionManager.appendCustomMessageEntry(
event.message.customType,
@ -343,30 +336,30 @@ export class AgentSession {
return undefined;
}
/** Emit hook events based on agent events */
private async _emitHookEvent(event: AgentEvent): Promise<void> {
if (!this._hookRunner) return;
/** Emit extension events based on agent events */
private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
if (!this._extensionRunner) return;
if (event.type === "agent_start") {
this._turnIndex = 0;
await this._hookRunner.emit({ type: "agent_start" });
await this._extensionRunner.emit({ type: "agent_start" });
} else if (event.type === "agent_end") {
await this._hookRunner.emit({ type: "agent_end", messages: event.messages });
await this._extensionRunner.emit({ type: "agent_end", messages: event.messages });
} else if (event.type === "turn_start") {
const hookEvent: TurnStartEvent = {
const extensionEvent: TurnStartEvent = {
type: "turn_start",
turnIndex: this._turnIndex,
timestamp: Date.now(),
};
await this._hookRunner.emit(hookEvent);
await this._extensionRunner.emit(extensionEvent);
} else if (event.type === "turn_end") {
const hookEvent: TurnEndEvent = {
const extensionEvent: TurnEndEvent = {
type: "turn_end",
turnIndex: this._turnIndex,
message: event.message,
toolResults: event.toolResults,
};
await this._hookRunner.emit(hookEvent);
await this._extensionRunner.emit(extensionEvent);
this._turnIndex++;
}
}
@ -517,9 +510,9 @@ export class AgentSession {
return this._scopedModels;
}
/** File-based slash commands */
get fileCommands(): ReadonlyArray<FileSlashCommand> {
return this._fileCommands;
/** File-based prompt templates */
get promptTemplates(): ReadonlyArray<PromptTemplate> {
return this._promptTemplates;
}
// =========================================================================
@ -528,28 +521,28 @@ export class AgentSession {
/**
* Send a prompt to the agent.
* - Handles hook commands (registered via pi.registerCommand) immediately, even during streaming
* - Expands file-based slash commands by default
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
* - Expands file-based prompt templates by default
* - During streaming, queues via steer() or followUp() based on streamingBehavior option
* - Validates model and API key before sending (when not streaming)
* @throws Error if streaming and no streamingBehavior specified
* @throws Error if no model selected or no API key available (when not streaming)
*/
async prompt(text: string, options?: PromptOptions): Promise<void> {
const expandCommands = options?.expandSlashCommands ?? true;
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
// Handle hook commands first (execute immediately, even during streaming)
// Hook commands manage their own LLM interaction via pi.sendMessage()
if (expandCommands && text.startsWith("/")) {
const handled = await this._tryExecuteHookCommand(text);
// Handle extension commands first (execute immediately, even during streaming)
// Extension commands manage their own LLM interaction via pi.sendMessage()
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) {
// Hook command executed, no prompt to send
// Extension command executed, no prompt to send
return;
}
}
// Expand file-based slash commands if requested
const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
// Expand file-based prompt templates if requested
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text;
// If streaming, queue via steer() or followUp() based on option
if (this.isStreaming) {
@ -593,7 +586,7 @@ export class AgentSession {
await this._checkCompaction(lastAssistant, false);
}
// Build messages array (hook message if any, then user message)
// Build messages array (custom message if any, then user message)
const messages: AgentMessage[] = [];
// Add user message
@ -613,14 +606,14 @@ export class AgentSession {
}
this._pendingNextTurnMessages = [];
// Emit before_agent_start hook event
if (this._hookRunner) {
const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
// Add all hook messages
// Emit before_agent_start extension event
if (this._extensionRunner) {
const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images);
// Add all custom messages from extensions
if (result?.messages) {
for (const msg of result.messages) {
messages.push({
role: "hookMessage",
role: "custom",
customType: msg.customType,
content: msg.content,
display: msg.display,
@ -629,7 +622,7 @@ export class AgentSession {
});
}
}
// Apply hook systemPromptAppend on top of base prompt
// Apply extension systemPromptAppend on top of base prompt
if (result?.systemPromptAppend) {
this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`);
} else {
@ -643,29 +636,29 @@ export class AgentSession {
}
/**
* Try to execute a hook command. Returns true if command was found and executed.
* Try to execute an extension command. Returns true if command was found and executed.
*/
private async _tryExecuteHookCommand(text: string): Promise<boolean> {
if (!this._hookRunner) return false;
private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
if (!this._extensionRunner) return false;
// Parse command name and args
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const command = this._hookRunner.getCommand(commandName);
const command = this._extensionRunner.getCommand(commandName);
if (!command) return false;
// Get command context from hook runner (includes session control methods)
const ctx = this._hookRunner.createCommandContext();
// Get command context from extension runner (includes session control methods)
const ctx = this._extensionRunner.createCommandContext();
try {
await command.handler(args, ctx);
return true;
} catch (err) {
// Emit error via hook runner
this._hookRunner.emitError({
hookPath: `command:${commandName}`,
// Emit error via extension runner
this._extensionRunner.emitError({
extensionPath: `command:${commandName}`,
event: "command",
error: err instanceof Error ? err.message : String(err),
});
@ -676,17 +669,17 @@ export class AgentSession {
/**
* Queue a steering message to interrupt the agent mid-run.
* Delivered after current tool execution, skips remaining tools.
* Expands file-based slash commands. Errors on hook commands.
* @throws Error if text is a hook command
* Expands file-based prompt templates. Errors on extension commands.
* @throws Error if text is an extension command
*/
async steer(text: string): Promise<void> {
// Check for hook commands (cannot be queued)
// Check for extension commands (cannot be queued)
if (text.startsWith("/")) {
this._throwIfHookCommand(text);
this._throwIfExtensionCommand(text);
}
// Expand file-based slash commands
const expandedText = expandSlashCommand(text, [...this._fileCommands]);
// Expand file-based prompt templates
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
await this._queueSteer(expandedText);
}
@ -694,23 +687,23 @@ export class AgentSession {
/**
* Queue a follow-up message to be processed after the agent finishes.
* Delivered only when agent has no more tool calls or steering messages.
* Expands file-based slash commands. Errors on hook commands.
* @throws Error if text is a hook command
* Expands file-based prompt templates. Errors on extension commands.
* @throws Error if text is an extension command
*/
async followUp(text: string): Promise<void> {
// Check for hook commands (cannot be queued)
// Check for extension commands (cannot be queued)
if (text.startsWith("/")) {
this._throwIfHookCommand(text);
this._throwIfExtensionCommand(text);
}
// Expand file-based slash commands
const expandedText = expandSlashCommand(text, [...this._fileCommands]);
// Expand file-based prompt templates
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
await this._queueFollowUp(expandedText);
}
/**
* Internal: Queue a steering message (already expanded, no hook command check).
* Internal: Queue a steering message (already expanded, no extension command check).
*/
private async _queueSteer(text: string): Promise<void> {
this._steeringMessages.push(text);
@ -722,7 +715,7 @@ export class AgentSession {
}
/**
* Internal: Queue a follow-up message (already expanded, no hook command check).
* Internal: Queue a follow-up message (already expanded, no extension command check).
*/
private async _queueFollowUp(text: string): Promise<void> {
this._followUpMessages.push(text);
@ -734,46 +727,46 @@ export class AgentSession {
}
/**
* Throw an error if the text is a hook command.
* Throw an error if the text is an extension command.
*/
private _throwIfHookCommand(text: string): void {
if (!this._hookRunner) return;
private _throwIfExtensionCommand(text: string): void {
if (!this._extensionRunner) return;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const command = this._hookRunner.getCommand(commandName);
const command = this._extensionRunner.getCommand(commandName);
if (command) {
throw new Error(
`Hook command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
);
}
}
/**
* Send a hook message to the session. Creates a CustomMessageEntry.
* Send a custom 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 message Custom message with customType, content, display, details
* @param options.triggerTurn If true and not streaming, triggers a new LLM turn
* @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
*/
async sendHookMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
async sendCustomMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): Promise<void> {
const appMessage = {
role: "hookMessage" as const,
role: "custom" as const,
customType: message.customType,
content: message.content,
display: message.display,
details: message.details,
timestamp: Date.now(),
} satisfies HookMessage<T>;
} satisfies CustomMessage<T>;
if (options?.deliverAs === "nextTurn") {
this._pendingNextTurnMessages.push(appMessage);
} else if (this.isStreaming) {
@ -842,14 +835,14 @@ export class AgentSession {
* Clears all messages and starts a new session.
* Listeners are preserved and will continue receiving events.
* @param options - Optional initial messages and parent session path
* @returns true if completed, false if cancelled by hook
* @returns true if completed, false if cancelled by extension
*/
async newSession(options?: NewSessionOptions): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
@ -868,9 +861,9 @@ export class AgentSession {
this._pendingNextTurnMessages = [];
this._reconnectToAgent();
// Emit session_switch event with reason "new" to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
// Emit session_switch event with reason "new" to extensions
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_switch",
reason: "new",
previousSessionFile,
@ -878,7 +871,6 @@ export class AgentSession {
}
// Emit session event to custom tools
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
return true;
}
@ -1097,11 +1089,11 @@ export class AgentSession {
throw new Error("Nothing to compact (session too small)");
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
if (this._hookRunner?.hasHandlers("session_before_compact")) {
const result = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
@ -1114,8 +1106,8 @@ export class AgentSession {
}
if (result?.compaction) {
hookCompaction = result.compaction;
fromHook = true;
extensionCompaction = result.compaction;
fromExtension = true;
}
}
@ -1124,12 +1116,12 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Hook provided compaction content
summary = hookCompaction.summary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
if (extensionCompaction) {
// Extension provided compaction content
summary = extensionCompaction.summary;
firstKeptEntryId = extensionCompaction.firstKeptEntryId;
tokensBefore = extensionCompaction.tokensBefore;
details = extensionCompaction.details;
} else {
// Generate compaction result
const result = await compact(
@ -1149,21 +1141,21 @@ export class AgentSession {
throw new Error("Compaction cancelled");
}
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.replaceMessages(sessionContext.messages);
// Get the saved compaction entry for the hook
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
| CompactionEntry
| undefined;
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
if (this._extensionRunner && savedCompactionEntry) {
await this._extensionRunner.emit({
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
fromExtension,
});
}
@ -1265,11 +1257,11 @@ export class AgentSession {
return;
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
if (this._hookRunner?.hasHandlers("session_before_compact")) {
const hookResult = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_compact")) {
const extensionResult = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
@ -1277,14 +1269,14 @@ export class AgentSession {
signal: this._autoCompactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
if (hookResult?.cancel) {
if (extensionResult?.cancel) {
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
return;
}
if (hookResult?.compaction) {
hookCompaction = hookResult.compaction;
fromHook = true;
if (extensionResult?.compaction) {
extensionCompaction = extensionResult.compaction;
fromExtension = true;
}
}
@ -1293,12 +1285,12 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Hook provided compaction content
summary = hookCompaction.summary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
if (extensionCompaction) {
// Extension provided compaction content
summary = extensionCompaction.summary;
firstKeptEntryId = extensionCompaction.firstKeptEntryId;
tokensBefore = extensionCompaction.tokensBefore;
details = extensionCompaction.details;
} else {
// Generate compaction result
const compactResult = await compact(
@ -1319,21 +1311,21 @@ export class AgentSession {
return;
}
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.replaceMessages(sessionContext.messages);
// Get the saved compaction entry for the hook
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
| CompactionEntry
| undefined;
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
if (this._extensionRunner && savedCompactionEntry) {
await this._extensionRunner.emit({
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
fromExtension,
});
}
@ -1632,14 +1624,14 @@ export class AgentSession {
* Switch to a different session file.
* Aborts current operation, loads messages, restores model/thinking.
* Listeners are preserved and will continue receiving events.
* @returns true if switch completed, false if cancelled by hook
* @returns true if switch completed, false if cancelled by extension
*/
async switchSession(sessionPath: string): Promise<boolean> {
const previousSessionFile = this.sessionManager.getSessionFile();
// Emit session_before_switch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_switch",
reason: "resume",
targetSessionFile: sessionPath,
@ -1662,9 +1654,9 @@ export class AgentSession {
// Reload messages
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session_switch event to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
// Emit session_switch event to extensions
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_switch",
reason: "resume",
previousSessionFile,
@ -1672,7 +1664,6 @@ export class AgentSession {
}
// Emit session event to custom tools
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
this.agent.replaceMessages(sessionContext.messages);
@ -1698,12 +1689,12 @@ export class AgentSession {
/**
* Create a branch from a specific entry.
* Emits before_branch/branch session events to hooks.
* Emits before_branch/branch session events to extensions.
*
* @param entryId ID of the entry to branch from
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - cancelled: True if a hook cancelled the branch
* - cancelled: True if an extension cancelled the branch
*/
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
const previousSessionFile = this.sessionFile;
@ -1718,8 +1709,8 @@ export class AgentSession {
let skipConversationRestore = false;
// Emit session_before_branch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_branch")) {
const result = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_branch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_branch",
entryId,
})) as SessionBeforeBranchResult | undefined;
@ -1742,16 +1733,15 @@ export class AgentSession {
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session_branch event to hooks (after branch completes)
if (this._hookRunner) {
await this._hookRunner.emit({
// Emit session_branch event to extensions (after branch completes)
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_branch",
previousSessionFile,
});
}
// Emit session event to custom tools (with reason "branch")
await this.emitCustomToolSessionEvent("branch", previousSessionFile);
if (!skipConversationRestore) {
this.agent.replaceMessages(sessionContext.messages);
@ -1812,12 +1802,12 @@ export class AgentSession {
// Set up abort controller for summarization
this._branchSummaryAbortController = new AbortController();
let hookSummary: { summary: string; details?: unknown } | undefined;
let fromHook = false;
let extensionSummary: { summary: string; details?: unknown } | undefined;
let fromExtension = false;
// Emit session_before_tree event
if (this._hookRunner?.hasHandlers("session_before_tree")) {
const result = (await this._hookRunner.emit({
if (this._extensionRunner?.hasHandlers("session_before_tree")) {
const result = (await this._extensionRunner.emit({
type: "session_before_tree",
preparation,
signal: this._branchSummaryAbortController.signal,
@ -1828,15 +1818,15 @@ export class AgentSession {
}
if (result?.summary && options.summarize) {
hookSummary = result.summary;
fromHook = true;
extensionSummary = result.summary;
fromExtension = true;
}
}
// Run default summarizer if needed
let summaryText: string | undefined;
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
const model = this.model!;
const apiKey = await this._modelRegistry.getApiKey(model);
if (!apiKey) {
@ -1862,9 +1852,9 @@ export class AgentSession {
readFiles: result.readFiles || [],
modifiedFiles: result.modifiedFiles || [],
};
} else if (hookSummary) {
summaryText = hookSummary.summary;
summaryDetails = hookSummary.details;
} else if (extensionSummary) {
summaryText = extensionSummary.summary;
summaryDetails = extensionSummary.details;
}
// Determine the new leaf position based on target type
@ -1895,7 +1885,7 @@ export class AgentSession {
let summaryEntry: BranchSummaryEntry | undefined;
if (summaryText) {
// Create summary at target position (can be null for root)
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook);
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
} else if (newLeafId === null) {
// No summary, navigating to root - reset leaf
@ -1910,18 +1900,17 @@ export class AgentSession {
this.agent.replaceMessages(sessionContext.messages);
// Emit session_tree event
if (this._hookRunner) {
await this._hookRunner.emit({
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_tree",
newLeafId: this.sessionManager.getLeafId(),
oldLeafId,
summaryEntry,
fromHook: summaryText ? fromHook : undefined,
fromExtension: summaryText ? fromExtension : undefined,
});
}
// Emit to custom tools
await this.emitCustomToolSessionEvent("tree", this.sessionFile);
this._branchSummaryAbortController = undefined;
return { editorText, cancelled: false, summaryEntry };
@ -2049,60 +2038,20 @@ export class AgentSession {
}
// =========================================================================
// Hook System
// Extension System
// =========================================================================
/**
* Check if hooks have handlers for a specific event type.
* Check if extensions have handlers for a specific event type.
*/
hasHookHandlers(eventType: string): boolean {
return this._hookRunner?.hasHandlers(eventType) ?? false;
hasExtensionHandlers(eventType: string): boolean {
return this._extensionRunner?.hasHandlers(eventType) ?? false;
}
/**
* Get the hook runner (for setting UI context and error handlers).
* Get the extension runner (for setting UI context and error handlers).
*/
get hookRunner(): HookRunner | undefined {
return this._hookRunner;
}
/**
* Get custom tools (for setting UI context in modes).
*/
get customTools(): LoadedCustomTool[] {
return this._customTools;
}
/**
* Emit session event to all custom tools.
* Called on session switch, branch, tree navigation, and shutdown.
*/
async emitCustomToolSessionEvent(
reason: CustomToolSessionEvent["reason"],
previousSessionFile?: string | undefined,
): Promise<void> {
if (!this._customTools) return;
const event: CustomToolSessionEvent = { reason, previousSessionFile };
const ctx: CustomToolContext = {
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
model: this.agent.state.model,
isIdle: () => !this.isStreaming,
hasPendingMessages: () => this.pendingMessageCount > 0,
abort: () => {
this.abort();
},
};
for (const { tool } of this._customTools) {
if (tool.onSession) {
try {
await tool.onSession(event, ctx);
} catch (_err) {
// Silently ignore tool errors during session events
}
}
}
get extensionRunner(): ExtensionRunner | undefined {
return this._extensionRunner;
}
}

View file

@ -12,7 +12,7 @@ import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
createCustomMessage,
} from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
@ -147,7 +147,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
return entry.message;
case "custom_message":
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@ -184,7 +184,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;

View file

@ -8,7 +8,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete, completeSimple } from "@mariozechner/pi-ai";
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import {
computeFileLists,
@ -44,6 +44,7 @@ function extractFileOperations(
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
// fromHook field kept for session file compatibility
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
@ -75,7 +76,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
return entry.message;
}
if (entry.type === "custom_message") {
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
}
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@ -88,7 +89,7 @@ export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
}
@ -194,7 +195,7 @@ export function estimateTokens(message: AgentMessage): number {
}
return Math.ceil(chars / 4);
}
case "hookMessage":
case "custom":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
@ -240,7 +241,7 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
const role = entry.message.role;
switch (role) {
case "bashExecution":
case "hookMessage":
case "custom":
case "branchSummary":
case "compactionSummary":
case "user":
@ -477,7 +478,7 @@ export async function generateSummary(
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
@ -515,7 +516,7 @@ export async function generateSummary(
}
// ============================================================================
// Compaction Preparation (for hooks)
// Compaction Preparation (for extensions)
// ============================================================================
export interface CompactionPreparation {

View file

@ -1,21 +0,0 @@
/**
* Custom tools module.
*/
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
export type {
AgentToolResult,
AgentToolUpdateCallback,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolResult,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
} from "./types.js";
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";

View file

@ -1,349 +0,0 @@
/**
* Custom tool loader - loads TypeScript tool modules using jiti.
*
* For Bun compiled binaries, custom tools that import from @mariozechner/* packages
* are not supported because Bun's plugin system doesn't intercept imports from
* external files loaded at runtime. Users should use the npm-installed version
* for custom tools that depend on pi packages.
*/
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir, isBunBinary } from "../../config.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import { createEventBus, type EventBus } from "../event-bus.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
// Lazily computed aliases - resolved at runtime to handle global installs
let _aliases: Record<string, string> | null = null;
function getAliases(): Record<string, string> {
if (_aliases) return _aliases;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageIndex = path.resolve(__dirname, "../..", "index.js");
// For typebox, we need the package root directory (not the entry file)
// because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler"
// get the alias prepended. If we alias to the entry file (.../build/cjs/index.js),
// then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid).
// By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly.
const typeboxEntry = require.resolve("@sinclair/typebox");
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
_aliases = {
"@mariozechner/pi-coding-agent": packageIndex,
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
"@sinclair/typebox": typeboxRoot,
};
return _aliases;
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
/**
* Resolve tool path.
* - Absolute paths used as-is
* - Paths starting with ~ expanded to home directory
* - Relative paths resolved from cwd
*/
function resolveToolPath(toolPath: string, cwd: string): string {
const expanded = expandPath(toolPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
// Relative paths resolved from cwd
return path.resolve(cwd, expanded);
}
/**
* Create a no-op UI context for headless modes.
*/
function createNoOpUIContext(): HookUIContext {
return {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
}
/**
* Load a tool in Bun binary mode.
*
* Since Bun plugins don't work for dynamically loaded external files,
* custom tools that import from @mariozechner/* packages won't work.
* Tools that only use standard npm packages (installed in the tool's directory)
* may still work.
*/
async function loadToolWithBun(
resolvedPath: string,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
try {
// Try to import directly - will work for tools without @mariozechner/* imports
const module = await import(resolvedPath);
const factory = (module.default ?? module) as CustomToolFactory;
if (typeof factory !== "function") {
return { tools: null, error: "Tool must export a default function" };
}
const toolResult = await factory(sharedApi);
const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({
path: resolvedPath,
resolvedPath,
tool,
}));
return { tools: loadedTools, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Check if it's a module resolution error for our packages
if (message.includes("Cannot find module") && message.includes("@mariozechner/")) {
return {
tools: null,
error:
`${message}\n` +
"Note: Custom tools importing from @mariozechner/* packages are not supported in the standalone binary.\n" +
"Please install pi via npm: npm install -g @mariozechner/pi-coding-agent",
};
}
return { tools: null, error: `Failed to load tool: ${message}` };
}
}
/**
* Load a single tool module using jiti (or Bun.build for compiled binaries).
*/
async function loadTool(
toolPath: string,
cwd: string,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
const resolvedPath = resolveToolPath(toolPath, cwd);
// Use Bun.build for compiled binaries since jiti can't resolve bundled modules
if (isBunBinary) {
return loadToolWithBun(resolvedPath, sharedApi);
}
try {
// Create jiti instance for TypeScript/ESM loading
// Use aliases to resolve package imports since tools are loaded from user directories
// (e.g. ~/.pi/agent/tools) but import from packages installed with pi-coding-agent
const jiti = createJiti(import.meta.url, {
alias: getAliases(),
});
// Import the module
const module = await jiti.import(resolvedPath, { default: true });
const factory = module as CustomToolFactory;
if (typeof factory !== "function") {
return { tools: null, error: "Tool must export a default function" };
}
// Call factory with shared API
const result = await factory(sharedApi);
// Handle single tool or array of tools
const toolsArray = Array.isArray(result) ? result : [result];
const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({
path: toolPath,
resolvedPath,
tool,
}));
return { tools: loadedTools, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { tools: null, error: `Failed to load tool: ${message}` };
}
}
/**
* Load all tools from configuration.
* @param paths - Array of tool file paths
* @param cwd - Current working directory for resolving relative paths
* @param builtInToolNames - Names of built-in tools to check for conflicts
*/
export async function loadCustomTools(
paths: string[],
cwd: string,
builtInToolNames: string[],
eventBus?: EventBus,
): Promise<CustomToolsLoadResult> {
const tools: LoadedCustomTool[] = [];
const errors: Array<{ path: string; error: string }> = [];
const seenNames = new Set<string>(builtInToolNames);
const resolvedEventBus = eventBus ?? createEventBus();
// Shared API object - all tools get the same instance
const sharedApi: CustomToolAPI = {
cwd,
exec: (command: string, args: string[], options?: ExecOptions) =>
execCommand(command, args, options?.cwd ?? cwd, options),
ui: createNoOpUIContext(),
hasUI: false,
events: resolvedEventBus,
sendMessage: () => {},
};
for (const toolPath of paths) {
const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi);
if (error) {
errors.push({ path: toolPath, error });
continue;
}
if (loadedTools) {
for (const loadedTool of loadedTools) {
// Check for name conflicts
if (seenNames.has(loadedTool.tool.name)) {
errors.push({
path: toolPath,
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
});
continue;
}
seenNames.add(loadedTool.tool.name);
tools.push(loadedTool);
}
}
}
return {
tools,
errors,
setUIContext(uiContext, hasUI) {
sharedApi.ui = uiContext;
sharedApi.hasUI = hasUI;
},
setSendMessageHandler(handler) {
sharedApi.sendMessage = handler;
},
};
}
/**
* Discover tool files from a directory.
* Only loads index.ts files from subdirectories (e.g., tools/mytool/index.ts).
*/
function discoverToolsInDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
const tools: string[] = [];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() || entry.isSymbolicLink()) {
// Check for index.ts in subdirectory
const indexPath = path.join(dir, entry.name, "index.ts");
if (fs.existsSync(indexPath)) {
tools.push(indexPath);
}
}
}
} catch {
return [];
}
return tools;
}
/**
* Discover and load tools from standard locations:
* 1. agentDir/tools/*.ts (global)
* 2. cwd/.pi/tools/*.ts (project-local)
*
* Plus any explicitly configured paths from settings or CLI.
*
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
* @param cwd - Current working directory
* @param builtInToolNames - Names of built-in tools to check for conflicts
* @param agentDir - Agent config directory. Default: from getAgentDir()
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
*/
export async function discoverAndLoadCustomTools(
configuredPaths: string[],
cwd: string,
builtInToolNames: string[],
agentDir: string = getAgentDir(),
eventBus?: EventBus,
): Promise<CustomToolsLoadResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
// Helper to add paths without duplicates
const addPaths = (paths: string[]) => {
for (const p of paths) {
const resolved = path.resolve(p);
if (!seen.has(resolved)) {
seen.add(resolved);
allPaths.push(p);
}
}
};
// 1. Global tools: agentDir/tools/
const globalToolsDir = path.join(agentDir, "tools");
addPaths(discoverToolsInDir(globalToolsDir));
// 2. Project-local tools: cwd/.pi/tools/
const localToolsDir = path.join(cwd, ".pi", "tools");
addPaths(discoverToolsInDir(localToolsDir));
// 3. Explicitly configured paths (can override/add)
addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd)));
return loadCustomTools(allPaths, cwd, builtInToolNames, eventBus);
}

View file

@ -1,199 +0,0 @@
/**
* Custom tool types.
*
* Custom tools are TypeScript modules that define additional tools for the agent.
* They can provide custom rendering for tool calls and results in the TUI.
*/
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { ReadonlySessionManager } from "../session-manager.js";
/** Alias for clarity */
export type CustomToolUIContext = HookUIContext;
/** Re-export for custom tools to use in execute signature */
export type { AgentToolResult, AgentToolUpdateCallback };
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/** API passed to custom tool factory (stable across session changes) */
export interface CustomToolAPI {
/** Current working directory */
cwd: string;
/** Execute a command */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** UI methods for user interaction (select, confirm, input, notify, custom) */
ui: CustomToolUIContext;
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
/** Shared event bus for tool/hook communication */
events: EventBus;
/**
* Send a message to the agent session.
*
* Delivery behavior depends on agent state and options:
* - Streaming + "steer" (default): Interrupt mid-run, delivered after current tool.
* - Streaming + "followUp": Wait until agent finishes before delivery.
* - Idle + triggerTurn: Triggers a new LLM turn immediately.
* - Idle + "nextTurn": Queue to be included with the next user message as context.
* - Idle + neither: Append to session history as standalone entry.
*
* @param message - The message to send
* @param message.customType - Identifier for your tool
* @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 tool-specific metadata (not sent to LLM)
* @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn
* @param options.deliverAs - Delivery mode: "steer", "followUp", or "nextTurn"
*/
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
}
/**
* Context passed to tool execute and onSession callbacks.
* Provides access to session state and model information.
*/
export interface CustomToolContext {
/** Session manager (read-only) */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Whether there are queued messages waiting to be processed */
hasPendingMessages(): boolean;
/** Abort the current agent operation (fire-and-forget, does not wait) */
abort(): void;
}
/** Session event passed to onSession callback */
export interface CustomToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start" and "shutdown" */
previousSessionFile: string | undefined;
}
/** Rendering options passed to renderResult */
export interface RenderResultOptions {
/** Whether the result view is expanded */
expanded: boolean;
/** Whether this is a partial/streaming result */
isPartial: boolean;
}
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
/**
* Custom tool definition.
*
* Custom tools are standalone - they don't extend AgentTool directly.
* When loaded, they are wrapped in an AgentTool for the agent to use.
*
* The execute callback receives a ToolContext with access to session state,
* model registry, and current model.
*
* @example
* ```typescript
* const factory: CustomToolFactory = (pi) => ({
* name: "my_tool",
* label: "My Tool",
* description: "Does something useful",
* parameters: Type.Object({ input: Type.String() }),
*
* async execute(toolCallId, params, onUpdate, ctx, signal) {
* // Access session state via ctx.sessionManager
* // Access model registry via ctx.modelRegistry
* // Current model via ctx.model
* return { content: [{ type: "text", text: "Done" }] };
* },
*
* onSession(event, ctx) {
* if (event.reason === "shutdown") {
* // Cleanup
* }
* // Reconstruct state from ctx.sessionManager.getEntries()
* }
* });
* ```
*/
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
label: string;
/** Description for LLM */
description: string;
/** Parameter schema (TypeBox) */
parameters: TParams;
/**
* Execute the tool.
* @param toolCallId - Unique ID for this tool call
* @param params - Parsed parameters matching the schema
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
* @param ctx - Context with session manager, model registry, and current model
* @param signal - Optional abort signal for cancellation
*/
execute(
toolCallId: string,
params: Static<TParams>,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
/** Custom rendering for tool call display - return a Component */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
/** Custom rendering for tool result display - return a Component */
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
}
/** Factory function that creates a custom tool or array of tools */
export type CustomToolFactory = (
pi: CustomToolAPI,
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
/** Loaded custom tool with metadata and wrapped AgentTool */
export interface LoadedCustomTool {
/** Original path (as specified) */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** The original custom tool instance */
tool: CustomTool;
}
/** Send message handler type for tool sendMessage */
export type ToolSendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
/** Result from loading custom tools */
export interface CustomToolsLoadResult {
tools: LoadedCustomTool[];
errors: Array<{ path: string; error: string }>;
/** Update the UI context for all loaded tools. Call when mode initializes. */
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
/** Set the sendMessage handler for all loaded tools. Call when session initializes. */
setSendMessageHandler(handler: ToolSendMessageHandler): void;
}

View file

@ -1,28 +0,0 @@
/**
* Wraps CustomTool instances into AgentTool for use with the agent.
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
/**
* Wrap a CustomTool into an AgentTool.
* The wrapper injects the ToolContext into execute calls.
*/
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
};
}
/**
* Wrap all loaded custom tools into AgentTools.
*/
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
}

View file

@ -1,5 +1,5 @@
/**
* Shared command execution utilities for hooks and custom tools.
* Shared command execution utilities for extensions and custom tools.
*/
import { spawn } from "node:child_process";

View file

@ -1,5 +1,5 @@
/**
* Extension system - unified hooks and custom tools.
* Extension system for lifecycle events and custom tools.
*/
export { discoverAndLoadExtensions, loadExtensions } from "./loader.js";

View file

@ -4,7 +4,7 @@
* Extensions are TypeScript modules that can:
* - Subscribe to agent lifecycle events
* - Register LLM-callable tools
* - Register slash commands, keyboard shortcuts, and CLI flags
* - Register commands, keyboard shortcuts, and CLI flags
* - Interact with the user via UI primitives
*/
@ -16,7 +16,7 @@ import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { CustomMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type {
BranchSummaryEntry,
@ -119,7 +119,7 @@ export interface ExtensionContext {
}
/**
* Extended context for slash command handlers.
* Extended context for command handlers.
* Includes session control methods only safe in user-initiated commands.
*/
export interface ExtensionCommandContext extends ExtensionContext {
@ -228,7 +228,7 @@ export interface SessionBeforeCompactEvent {
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromHook: boolean;
fromExtension: boolean;
}
/** Fired on process exit */
@ -258,7 +258,7 @@ export interface SessionTreeEvent {
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromHook?: boolean;
fromExtension?: boolean;
}
export type SessionEvent =
@ -442,7 +442,7 @@ export interface ToolResultEventResult {
}
export interface BeforeAgentStartEventResult {
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details">;
systemPromptAppend?: string;
}
@ -477,7 +477,7 @@ export interface MessageRenderOptions {
}
export type MessageRenderer<T = unknown> = (
message: HookMessage<T>,
message: CustomMessage<T>,
options: MessageRenderOptions,
theme: Theme,
) => Component | undefined;
@ -547,7 +547,7 @@ export interface ExtensionAPI {
// Command, Shortcut, Flag Registration
// =========================================================================
/** Register a custom slash command. */
/** Register a custom command. */
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
/** Register a keyboard shortcut. */
@ -576,7 +576,7 @@ export interface ExtensionAPI {
// Message Rendering
// =========================================================================
/** Register a custom renderer for HookMessageEntry. */
/** Register a custom renderer for CustomMessageEntry. */
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
// =========================================================================
@ -585,7 +585,7 @@ export interface ExtensionAPI {
/** Send a custom message to the session. */
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
@ -638,7 +638,7 @@ export interface ExtensionShortcut {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;

View file

@ -1,21 +0,0 @@
// biome-ignore assist/source/organizeImports: biome is not smart
export {
discoverAndLoadHooks,
loadHooks,
type AppendEntryHandler,
type BranchHandler,
type GetActiveToolsHandler,
type GetAllToolsHandler,
type HookFlag,
type HookShortcut,
type LoadedHook,
type LoadHooksResult,
type NavigateTreeHandler,
type NewSessionHandler,
type SendMessageHandler,
type SetActiveToolsHandler,
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export * from "./types.js";
export type { ReadonlySessionManager } from "../session-manager.js";

View file

@ -1,505 +0,0 @@
/**
* Hook loader - loads TypeScript hook modules using jiti.
*/
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { KeyId } from "@mariozechner/pi-tui";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import { createEventBus, type EventBus } from "../event-bus.js";
import type { HookMessage } from "../messages.js";
import type { SessionManager } from "../session-manager.js";
import { execCommand } from "./runner.js";
import type {
ExecOptions,
HookAPI,
HookContext,
HookFactory,
HookMessageRenderer,
RegisteredCommand,
} from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
// Lazily computed aliases - resolved at runtime to handle global installs
let _aliases: Record<string, string> | null = null;
function getAliases(): Record<string, string> {
if (_aliases) return _aliases;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageIndex = path.resolve(__dirname, "../..", "index.js");
// For typebox, we need the package root directory (not the entry file)
// because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler"
// get the alias prepended. If we alias to the entry file (.../build/cjs/index.js),
// then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid).
// By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly.
const typeboxEntry = require.resolve("@sinclair/typebox");
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
_aliases = {
"@mariozechner/pi-coding-agent": packageIndex,
"@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"),
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
"@sinclair/typebox": typeboxRoot,
};
return _aliases;
}
/**
* Generic handler function type.
*/
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Send message handler type for pi.sendMessage().
*/
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
/**
* Append entry handler type for pi.appendEntry().
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* Get active tools handler type for pi.getActiveTools().
*/
export type GetActiveToolsHandler = () => string[];
/**
* Get all tools handler type for pi.getAllTools().
*/
export type GetAllToolsHandler = () => string[];
/**
* Set active tools handler type for pi.setActiveTools().
*/
export type SetActiveToolsHandler = (toolNames: string[]) => void;
/**
* CLI flag definition registered by a hook.
*/
export interface HookFlag {
/** Flag name (without --) */
name: string;
/** Description for --help */
description?: string;
/** Type: boolean or string */
type: "boolean" | "string";
/** Default value */
default?: boolean | string;
/** Hook path that registered this flag */
hookPath: string;
}
/**
* Keyboard shortcut registered by a hook.
*/
export interface HookShortcut {
/** Key identifier (e.g., Key.shift("p"), "ctrl+x") */
shortcut: KeyId;
/** Description for help */
description?: string;
/** Handler function */
handler: (ctx: HookContext) => Promise<void> | void;
/** Hook path that registered this shortcut */
hookPath: string;
}
/**
* New session handler type for ctx.newSession() in HookCommandContext.
*/
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
/**
* Branch handler type for ctx.branch() in HookCommandContext.
*/
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
/**
* Navigate tree handler type for ctx.navigateTree() in HookCommandContext.
*/
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
/**
* Registered handlers for a loaded hook.
*/
export interface LoadedHook {
/** Original path from config */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>;
/** Map of customType to hook message renderer */
messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** CLI flags registered by this hook */
flags: Map<string, HookFlag>;
/** Flag values (set after CLI parsing) */
flagValues: Map<string, boolean | string>;
/** Keyboard shortcuts registered by this hook */
shortcuts: Map<KeyId, HookShortcut>;
/** 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;
/** Set the get active tools handler for this hook's pi.getActiveTools() */
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
/** Set the get all tools handler for this hook's pi.getAllTools() */
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
/** Set the set active tools handler for this hook's pi.setActiveTools() */
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
/** Set a flag value (called after CLI parsing) */
setFlagValue: (name: string, value: boolean | string) => void;
}
/**
* Result of loading hooks.
*/
export interface LoadHooksResult {
/** Successfully loaded hooks */
hooks: LoadedHook[];
/** Errors encountered during loading */
errors: Array<{ path: string; error: string }>;
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
/**
* Resolve hook path.
* - Absolute paths used as-is
* - Paths starting with ~ expanded to home directory
* - Relative paths resolved from cwd
*/
function resolveHookPath(hookPath: string, cwd: string): string {
const expanded = expandPath(hookPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
// Relative paths resolved from cwd
return path.resolve(cwd, expanded);
}
/**
* Create a HookAPI instance that collects handlers, renderers, and commands.
* Returns the API, maps, and functions to set handlers later.
*/
function createHookAPI(
handlers: Map<string, HandlerFn[]>,
cwd: string,
hookPath: string,
eventBus: EventBus,
): {
api: HookAPI;
messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, HookFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, HookShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
setFlagValue: (name: string, value: boolean | string) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
};
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
let getAllToolsHandler: GetAllToolsHandler = () => [];
let setActiveToolsHandler: SetActiveToolsHandler = () => {
// Default no-op until mode sets the handler
};
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const flags = new Map<string, HookFlag>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<KeyId, HookShortcut>();
// Cast to HookAPI - the implementation is more general (string event names)
// but the interface has specific overloads for type safety in hooks
const api = {
on(event: string, handler: HandlerFn): void {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
sendMessage<T = unknown>(
message: HookMessage<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): void {
sendMessageHandler(message, options);
},
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);
},
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
messageRenderers.set(customType, renderer as HookMessageRenderer);
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
},
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
getActiveTools(): string[] {
return getActiveToolsHandler();
},
getAllTools(): string[] {
return getAllToolsHandler();
},
setActiveTools(toolNames: string[]): void {
setActiveToolsHandler(toolNames);
},
registerFlag(
name: string,
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
): void {
flags.set(name, { name, hookPath, ...options });
if (options.default !== undefined) {
flagValues.set(name, options.default);
}
},
getFlag(name: string): boolean | string | undefined {
return flagValues.get(name);
},
registerShortcut(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: HookContext) => Promise<void> | void;
},
): void {
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
},
events: eventBus,
} as HookAPI;
return {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => {
getActiveToolsHandler = handler;
},
setGetAllToolsHandler: (handler: GetAllToolsHandler) => {
getAllToolsHandler = handler;
},
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => {
setActiveToolsHandler = handler;
},
setFlagValue: (name: string, value: boolean | string) => {
flagValues.set(name, value);
},
};
}
/**
* Load a single hook module using jiti.
*/
async function loadHook(
hookPath: string,
cwd: string,
eventBus: EventBus,
): Promise<{ hook: LoadedHook | null; error: string | null }> {
const resolvedPath = resolveHookPath(hookPath, cwd);
try {
// Create jiti instance for TypeScript/ESM loading
// Use aliases to resolve package imports since hooks are loaded from user directories
// (e.g. ~/.pi/agent/hooks) but import from packages installed with pi-coding-agent
const jiti = createJiti(import.meta.url, {
alias: getAliases(),
});
// Import the module
const module = await jiti.import(resolvedPath, { default: true });
const factory = module as HookFactory;
if (typeof factory !== "function") {
return { hook: null, error: "Hook must export a default function" };
}
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
} = createHookAPI(handlers, cwd, hookPath, eventBus);
// Call factory to register handlers
factory(api);
return {
hook: {
path: hookPath,
resolvedPath,
handlers,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
},
error: null,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { hook: null, error: `Failed to load hook: ${message}` };
}
}
/**
* Load all hooks from configuration.
* @param paths - Array of hook file paths
* @param cwd - Current working directory for resolving relative paths
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
*/
export async function loadHooks(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadHooksResult> {
const hooks: LoadedHook[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedEventBus = eventBus ?? createEventBus();
for (const hookPath of paths) {
const { hook, error } = await loadHook(hookPath, cwd, resolvedEventBus);
if (error) {
errors.push({ path: hookPath, error });
continue;
}
if (hook) {
hooks.push(hook);
}
}
return { hooks, errors };
}
/**
* Discover hook files from a directory.
* Returns all .ts files (and symlinks to .ts files) in the directory (non-recursive).
*/
function discoverHooksInDir(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries
.filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts"))
.map((e) => path.join(dir, e.name));
} catch {
return [];
}
}
/**
* Discover and load hooks from standard locations:
* 1. agentDir/hooks/*.ts (global)
* 2. cwd/.pi/hooks/*.ts (project-local)
*
* Plus any explicitly configured paths from settings.
*
* @param configuredPaths - Explicitly configured hook paths
* @param cwd - Current working directory
* @param agentDir - Agent configuration directory
* @param eventBus - Optional shared event bus (creates isolated bus if not provided)
*/
export async function discoverAndLoadHooks(
configuredPaths: string[],
cwd: string,
agentDir: string = getAgentDir(),
eventBus?: EventBus,
): Promise<LoadHooksResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
// Helper to add paths without duplicates
const addPaths = (paths: string[]) => {
for (const p of paths) {
const resolved = path.resolve(p);
if (!seen.has(resolved)) {
seen.add(resolved);
allPaths.push(p);
}
}
};
// 1. Global hooks: agentDir/hooks/
const globalHooksDir = path.join(agentDir, "hooks");
addPaths(discoverHooksInDir(globalHooksDir));
// 2. Project-local hooks: cwd/.pi/hooks/
const localHooksDir = path.join(cwd, ".pi", "hooks");
addPaths(discoverHooksInDir(localHooksDir));
// 3. Explicitly configured paths (can override/add)
addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd)));
return loadHooks(allPaths, cwd, eventBus);
}

View file

@ -1,552 +0,0 @@
/**
* Hook runner - executes hooks and manages their lifecycle.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type {
AppendEntryHandler,
BranchHandler,
HookFlag,
HookShortcut,
LoadedHook,
NavigateTreeHandler,
NewSessionHandler,
SendMessageHandler,
} from "./loader.js";
import type {
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
HookCommandContext,
HookContext,
HookError,
HookEvent,
HookMessageRenderer,
HookUIContext,
RegisteredCommand,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
} from "./types.js";
/** Combined result from all before_agent_start handlers (internal) */
interface BeforeAgentStartCombinedResult {
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
systemPromptAppend?: string;
}
/**
* Listener for hook errors.
*/
export type HookErrorListener = (error: HookError) => void;
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
/**
* HookRunner executes hooks and manages event emission.
*/
export class HookRunner {
private hooks: LoadedHook[];
private uiContext: HookUIContext;
private hasUI: boolean;
private cwd: string;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true;
private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {};
private hasPendingMessagesFn: () => boolean = () => false;
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
private branchHandler: BranchHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
this.hooks = hooks;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
}
/**
* Initialize HookRunner with all required context.
* Modes call this once the agent session is fully set up.
*/
initialize(options: {
/** Function to get the current model */
getModel: () => Model<any> | undefined;
/** Handler for hooks to send messages */
sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler;
/** Handler for getting current active tools */
getActiveToolsHandler: () => string[];
/** Handler for getting all configured tools */
getAllToolsHandler: () => string[];
/** Handler for setting active tools */
setActiveToolsHandler: (toolNames: string[]) => void;
/** Handler for creating new sessions (for HookCommandContext) */
newSessionHandler?: NewSessionHandler;
/** Handler for branching sessions (for HookCommandContext) */
branchHandler?: BranchHandler;
/** Handler for navigating session tree (for HookCommandContext) */
navigateTreeHandler?: NavigateTreeHandler;
/** Function to check if agent is idle */
isIdle?: () => boolean;
/** Function to wait for agent to be idle */
waitForIdle?: () => Promise<void>;
/** Function to abort current operation (fire-and-forget) */
abort?: () => void;
/** Function to check if there are queued messages */
hasPendingMessages?: () => boolean;
/** UI context for interactive prompts */
uiContext?: HookUIContext;
/** Whether UI is available */
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
this.isIdleFn = options.isIdle ?? (() => true);
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
this.abortFn = options.abort ?? (() => {});
this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false);
// Store session handlers for HookCommandContext
if (options.newSessionHandler) {
this.newSessionHandler = options.newSessionHandler;
}
if (options.branchHandler) {
this.branchHandler = options.branchHandler;
}
if (options.navigateTreeHandler) {
this.navigateTreeHandler = options.navigateTreeHandler;
}
// Set per-hook handlers for pi.sendMessage(), pi.appendEntry(), pi.getActiveTools(), pi.getAllTools(), pi.setActiveTools()
for (const hook of this.hooks) {
hook.setSendMessageHandler(options.sendMessageHandler);
hook.setAppendEntryHandler(options.appendEntryHandler);
hook.setGetActiveToolsHandler(options.getActiveToolsHandler);
hook.setGetAllToolsHandler(options.getAllToolsHandler);
hook.setSetActiveToolsHandler(options.setActiveToolsHandler);
}
this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false;
}
/**
* Get the UI context (set by mode).
*/
getUIContext(): HookUIContext | null {
return this.uiContext;
}
/**
* Get whether UI is available.
*/
getHasUI(): boolean {
return this.hasUI;
}
/**
* Get the paths of all loaded hooks.
*/
getHookPaths(): string[] {
return this.hooks.map((h) => h.path);
}
/**
* Get all CLI flags registered by hooks.
*/
getFlags(): Map<string, HookFlag> {
const allFlags = new Map<string, HookFlag>();
for (const hook of this.hooks) {
for (const [name, flag] of hook.flags) {
allFlags.set(name, flag);
}
}
return allFlags;
}
/**
* Set a flag value (after CLI parsing).
*/
setFlagValue(name: string, value: boolean | string): void {
for (const hook of this.hooks) {
if (hook.flags.has(name)) {
hook.setFlagValue(name, value);
}
}
}
// Built-in shortcuts that hooks should not override
private static readonly RESERVED_SHORTCUTS = new Set([
"ctrl+c",
"ctrl+d",
"ctrl+z",
"ctrl+k",
"ctrl+p",
"ctrl+l",
"ctrl+o",
"ctrl+t",
"ctrl+g",
"shift+tab",
"shift+ctrl+p",
"alt+enter",
"escape",
"enter",
]);
/**
* Get all keyboard shortcuts registered by hooks.
* When multiple hooks register the same shortcut, the last one wins.
* Conflicts with built-in shortcuts are skipped with a warning.
* Conflicts between hooks are logged as warnings.
*/
getShortcuts(): Map<KeyId, HookShortcut> {
const allShortcuts = new Map<KeyId, HookShortcut>();
for (const hook of this.hooks) {
for (const [key, shortcut] of hook.shortcuts) {
// Normalize to lowercase for comparison (KeyId is string at runtime)
const normalizedKey = key.toLowerCase() as KeyId;
// Check for built-in shortcut conflicts
if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {
console.warn(
`Hook shortcut '${key}' from ${shortcut.hookPath} conflicts with built-in shortcut. Skipping.`,
);
continue;
}
const existing = allShortcuts.get(normalizedKey);
if (existing) {
// Log conflict between hooks - last one wins
console.warn(
`Hook shortcut conflict: '${key}' registered by both ${existing.hookPath} and ${shortcut.hookPath}. Using ${shortcut.hookPath}.`,
);
}
allShortcuts.set(normalizedKey, shortcut);
}
}
return allShortcuts;
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function
*/
onError(listener: HookErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
/**
* Emit an error to all listeners.
*/
/**
* Emit an error to all error listeners.
*/
emitError(error: HookError): void {
for (const listener of this.errorListeners) {
listener(error);
}
}
/**
* Check if any hooks have handlers for the given event type.
*/
hasHandlers(eventType: string): boolean {
for (const hook of this.hooks) {
const handlers = hook.handlers.get(eventType);
if (handlers && handlers.length > 0) {
return true;
}
}
return false;
}
/**
* Get a message renderer for the given customType.
* Returns the first renderer found across all hooks, or undefined if none.
*/
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
for (const hook of this.hooks) {
const renderer = hook.messageRenderers.get(customType);
if (renderer) {
return renderer;
}
}
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.
*/
private createContext(): HookContext {
return {
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
isIdle: () => this.isIdleFn(),
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
};
}
/**
* Create the command context for slash command handlers.
* Extends HookContext with session control methods that are only safe in commands.
*/
createCommandContext(): HookCommandContext {
return {
...this.createContext(),
waitForIdle: () => this.waitForIdleFn(),
newSession: (options) => this.newSessionHandler(options),
branch: (entryId) => this.branchHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
};
}
/**
* Check if event type is a session "before_*" event that can be cancelled.
*/
private isSessionBeforeEvent(
type: string,
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_branch" ||
type === "session_before_compact" ||
type === "session_before_tree"
);
}
/**
* Emit an event to all hooks.
* Returns the result from session before_* / tool_result events (if any handler returns one).
*/
async emit(
event: HookEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
// For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;
}
}
// For tool_result events, capture the result
if (event.type === "tool_result" && handlerResult) {
result = handlerResult as ToolResultEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
hookPath: hook.path,
event: event.type,
error: message,
stack,
});
}
}
}
return result;
}
/**
* Emit a tool_call event to all hooks.
* No timeout - user prompts can take as long as needed.
* Errors are thrown (not swallowed) so caller can block on failure.
*/
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
const ctx = this.createContext();
let result: ToolCallEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get("tool_call");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
// No timeout - let user take their time
const handlerResult = await handler(event, ctx);
if (handlerResult) {
result = handlerResult as ToolCallEventResult;
// If blocked, stop processing further hooks
if (result.block) {
return result;
}
}
}
}
return result;
}
/**
* Emit a context event to all hooks.
* Handlers are chained - each gets the previous handler's output (if any).
* Returns the final modified messages, or the original if no modifications.
*
* Messages are deep-copied before passing to hooks, so mutations are safe.
*/
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = structuredClone(messages);
for (const hook of this.hooks) {
const handlers = hook.handlers.get("context");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
hookPath: hook.path,
event: "context",
error: message,
stack,
});
}
}
}
return currentMessages;
}
/**
* Emit before_agent_start event to all hooks.
* Returns combined result: all messages and all systemPromptAppend strings concatenated.
*/
async emitBeforeAgentStart(
prompt: string,
images?: ImageContent[],
): Promise<BeforeAgentStartCombinedResult | undefined> {
const ctx = this.createContext();
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
const systemPromptAppends: string[] = [];
for (const hook of this.hooks) {
const handlers = hook.handlers.get("before_agent_start");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const handlerResult = await handler(event, ctx);
if (handlerResult) {
const result = handlerResult as BeforeAgentStartEventResult;
// Collect all messages
if (result.message) {
messages.push(result.message);
}
// Collect all systemPromptAppend strings
if (result.systemPromptAppend) {
systemPromptAppends.push(result.systemPromptAppend);
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
hookPath: hook.path,
event: "before_agent_start",
error: message,
stack,
});
}
}
}
// Return combined result
if (messages.length > 0 || systemPromptAppends.length > 0) {
return {
messages: messages.length > 0 ? messages : undefined,
systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined,
};
}
return undefined;
}
}

View file

@ -1,98 +0,0 @@
/**
* Tool wrapper - wraps tools with hook callbacks for interception.
*/
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { HookRunner } from "./runner.js";
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
/**
* Wrap a tool with hook callbacks.
* - Emits tool_call event before execution (can block)
* - Emits tool_result event after execution (can modify result)
* - Forwards onUpdate callback to wrapped tool for progress streaming
*/
export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRunner): AgentTool<any, T> {
return {
...tool,
execute: async (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
) => {
// Emit tool_call event - hooks can block execution
// If hook errors/times out, block by default (fail-safe)
if (hookRunner.hasHandlers("tool_call")) {
try {
const callResult = (await hookRunner.emitToolCall({
type: "tool_call",
toolName: tool.name,
toolCallId,
input: params,
})) as ToolCallEventResult | undefined;
if (callResult?.block) {
const reason = callResult.reason || "Tool execution was blocked by a hook";
throw new Error(reason);
}
} catch (err) {
// Hook error or block - throw to mark as error
if (err instanceof Error) {
throw err;
}
throw new Error(`Hook failed, blocking execution: ${String(err)}`);
}
}
// Execute the actual tool, forwarding onUpdate for progress streaming
try {
const result = await tool.execute(toolCallId, params, signal, onUpdate);
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
}
}
return result;
} catch (err) {
// Emit tool_result event for errors so hooks can observe failures
if (hookRunner.hasHandlers("tool_result")) {
await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
});
}
throw err; // Re-throw original error for agent-loop
}
},
};
}
/**
* Wrap all tools with hook callbacks.
*/
export function wrapToolsWithHooks<T>(tools: AgentTool<any, T>[], hookRunner: HookRunner): AgentTool<any, T>[] {
return tools.map((tool) => wrapToolWithHooks(tool, hookRunner));
}

View file

@ -1,942 +0,0 @@
/**
* Hook system types.
*
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
* and interact with the user via UI primitives.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
BashToolDetails,
FindToolDetails,
GrepToolDetails,
LsToolDetails,
ReadToolDetails,
} from "../tools/index.js";
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/**
* UI context for hooks to request interactive UI from the harness.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
export interface HookUIContext {
/**
* Show a selector and return the user's choice.
* @param title - Title to display
* @param options - Array of string options
* @returns Selected option string, or null if cancelled
*/
select(title: string, options: string[]): Promise<string | undefined>;
/**
* Show a confirmation dialog.
* @returns true if confirmed, false if cancelled
*/
confirm(title: string, message: string): Promise<boolean>;
/**
* Show a text input dialog.
* @returns User input, or undefined if cancelled
*/
input(title: string, placeholder?: string): Promise<string | undefined>;
/**
* Show a notification to the user.
*/
notify(message: string, type?: "info" | "warning" | "error"): void;
/**
* Set status text in the footer/status bar.
* Pass undefined as text to clear the status for this key.
* Text can include ANSI escape codes for styling.
* Note: Newlines, tabs, and carriage returns are replaced with spaces.
* The combined status line is truncated to terminal width.
* @param key - Unique key to identify this status (e.g., hook name)
* @param text - Status text to display, or undefined to clear
*/
setStatus(key: string, text: string | undefined): void;
/**
* Set a widget to display in the status area (above the editor, below "Working..." indicator).
* Supports multi-line content. Pass undefined to clear.
* Text can include ANSI escape codes for styling.
*
* Accepts either an array of styled strings, or a factory function that creates a Component.
*
* @param key - Unique key to identify this widget (e.g., hook name)
* @param content - Array of lines to display, or undefined to clear
*
* @example
* // Show a todo list with styled strings
* ctx.ui.setWidget("plan-todos", [
* theme.fg("accent", "Plan Progress:"),
* "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")),
* "☐ Step 2: Modify code",
* "☐ Step 3: Run tests",
* ]);
*
* // Clear the widget
* ctx.ui.setWidget("plan-todos", undefined);
*/
setWidget(key: string, content: string[] | undefined): void;
/**
* Set a custom component as a widget (above the editor, below "Working..." indicator).
* Unlike custom(), this does NOT take keyboard focus - the editor remains focused.
* Pass undefined to clear the widget.
*
* The component should implement render(width) and optionally dispose().
* Components are rendered inline without taking focus - they cannot handle keyboard input.
*
* @param key - Unique key to identify this widget (e.g., hook name)
* @param content - Factory function that creates the component, or undefined to clear
*
* @example
* // Show a custom progress component
* ctx.ui.setWidget("my-progress", (tui, theme) => {
* return new MyProgressComponent(tui, theme);
* });
*
* // Clear the widget
* ctx.ui.setWidget("my-progress", undefined);
*/
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/**
* Set the terminal window/tab title.
* Uses OSC escape sequence (works in most modern terminals).
* @param title - Title text to display
*/
setTitle(title: string): void;
/**
* Show a custom component with keyboard focus.
* The factory receives TUI, theme, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
* return loader;
* });
*/
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/**
* Set the text in the core input editor.
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
* @param text - Text to set in the editor
*/
setEditorText(text: string): void;
/**
* Get the current text from the core input editor.
* @returns Current editor text
*/
getEditorText(): string;
/**
* Show a multi-line editor for text editing.
* Supports Ctrl+G to open external editor ($VISUAL or $EDITOR).
* @param title - Title describing what is being edited
* @param prefill - Optional initial text
* @returns Edited text, or undefined if cancelled (Escape)
*/
editor(title: string, prefill?: string): Promise<string | undefined>;
/**
* Get the current theme for styling text with ANSI codes.
* Use theme.fg() and theme.bg() to style status text.
*
* @example
* const theme = ctx.ui.theme;
* ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + " Ready");
*/
readonly theme: Theme;
}
/**
* Context passed to hook event handlers.
* For command handlers, see HookCommandContext which extends this with session control methods.
*/
export interface HookContext {
/** UI methods for user interaction */
ui: HookUIContext;
/** Whether UI is available (false in print mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Abort the current agent operation (fire-and-forget, does not wait) */
abort(): void;
/** Whether there are queued messages waiting to be processed */
hasPendingMessages(): boolean;
}
/**
* Extended context for slash command handlers.
* Includes session control methods that are only safe in user-initiated commands.
*
* These methods are not available in event handlers because they can cause
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
*/
export interface HookCommandContext extends HookContext {
/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;
/**
* Start a new session, optionally with a setup callback to initialize it.
* The setup callback receives a writable SessionManager for the new session.
*
* @param options.parentSession - Path to parent session for lineage tracking
* @param options.setup - Async callback to initialize the new session (e.g., append messages)
* @returns Object with `cancelled: true` if a hook cancelled the new session
*
* @example
* // Handoff: summarize current session and start fresh with context
* await ctx.newSession({
* parentSession: ctx.sessionManager.getSessionFile(),
* setup: async (sm) => {
* sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] });
* }
* });
*/
newSession(options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
/**
* Branch from a specific entry, creating a new session file.
*
* @param entryId - ID of the entry to branch from
* @returns Object with `cancelled: true` if a hook cancelled the branch
*/
branch(entryId: string): Promise<{ cancelled: boolean }>;
/**
* Navigate to a different point in the session tree (in-place).
*
* @param targetId - ID of the entry to navigate to
* @param options.summarize - Whether to summarize the abandoned branch
* @returns Object with `cancelled: true` if a hook cancelled the navigation
*/
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
}
// ============================================================================
// Session Events
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromHook?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/**
* Event data for context event.
* Fired before each LLM call, allowing hooks to modify context non-destructively.
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/**
* Event data for before_agent_start event.
* Fired after user submits a prompt but before the agent loop starts.
* Allows hooks to inject context that will be persisted and visible in TUI.
*/
export interface BeforeAgentStartEvent {
type: "before_agent_start";
/** The user's prompt text */
prompt: string;
/** Any images attached to the prompt */
images?: ImageContent[];
}
/**
* Event data for agent_start event.
* Fired when an agent loop starts (once per user prompt).
*/
export interface AgentStartEvent {
type: "agent_start";
}
/**
* Event data for agent_end event.
*/
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/**
* Event data for turn_start event.
*/
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/**
* Event data for turn_end event.
*/
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
/**
* Event data for tool_call event.
* Fired before a tool is executed. Hooks can block execution.
*/
export interface ToolCallEvent {
type: "tool_call";
/** Tool name (e.g., "bash", "edit", "write") */
toolName: string;
/** Tool call ID */
toolCallId: string;
/** Tool input parameters */
input: Record<string, unknown>;
}
/**
* Base interface for tool_result events.
*/
interface ToolResultEventBase {
type: "tool_result";
/** Tool call ID */
toolCallId: string;
/** Tool input parameters */
input: Record<string, unknown>;
/** Full content array (text and images) */
content: (TextContent | ImageContent)[];
/** Whether the tool execution was an error */
isError: boolean;
}
/** Tool result event for bash tool */
export interface BashToolResultEvent extends ToolResultEventBase {
toolName: "bash";
details: BashToolDetails | undefined;
}
/** Tool result event for read tool */
export interface ReadToolResultEvent extends ToolResultEventBase {
toolName: "read";
details: ReadToolDetails | undefined;
}
/** Tool result event for edit tool */
export interface EditToolResultEvent extends ToolResultEventBase {
toolName: "edit";
details: EditToolDetails | undefined;
}
/** Tool result event for write tool */
export interface WriteToolResultEvent extends ToolResultEventBase {
toolName: "write";
details: undefined;
}
/** Tool result event for grep tool */
export interface GrepToolResultEvent extends ToolResultEventBase {
toolName: "grep";
details: GrepToolDetails | undefined;
}
/** Tool result event for find tool */
export interface FindToolResultEvent extends ToolResultEventBase {
toolName: "find";
details: FindToolDetails | undefined;
}
/** Tool result event for ls tool */
export interface LsToolResultEvent extends ToolResultEventBase {
toolName: "ls";
details: LsToolDetails | undefined;
}
/** Tool result event for custom/unknown tools */
export interface CustomToolResultEvent extends ToolResultEventBase {
toolName: string;
details: unknown;
}
/**
* Event data for tool_result event.
* Fired after a tool is executed. Hooks can modify the result.
* Use toolName to discriminate and get typed details.
*/
export type ToolResultEvent =
| BashToolResultEvent
| ReadToolResultEvent
| EditToolResultEvent
| WriteToolResultEvent
| GrepToolResultEvent
| FindToolResultEvent
| LsToolResultEvent
| CustomToolResultEvent;
// Type guards for narrowing ToolResultEvent to specific tool types
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
return e.toolName === "bash";
}
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
return e.toolName === "read";
}
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
return e.toolName === "edit";
}
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
return e.toolName === "write";
}
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
return e.toolName === "grep";
}
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
return e.toolName === "find";
}
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
return e.toolName === "ls";
}
/**
* Union of all hook event types.
*/
export type HookEvent =
| SessionEvent
| ContextEvent
| BeforeAgentStartEvent
| AgentStartEvent
| AgentEndEvent
| TurnStartEvent
| TurnEndEvent
| ToolCallEvent
| ToolResultEvent;
// ============================================================================
// Event Results
// ============================================================================
/**
* Return type for context event handlers.
* Allows hooks to modify messages before they're sent to the LLM.
*/
export interface ContextEventResult {
/** Modified messages to send instead of the original */
messages?: AgentMessage[];
}
/**
* Return type for tool_call event handlers.
* Allows hooks to block tool execution.
*/
export interface ToolCallEventResult {
/** If true, block the tool from executing */
block?: boolean;
/** Reason for blocking (returned to LLM as error) */
reason?: string;
}
/**
* Return type for tool_result event handlers.
* Allows hooks to modify tool results.
*/
export interface ToolResultEventResult {
/** Replacement content array (text and images) */
content?: (TextContent | ImageContent)[];
/** Replacement details */
details?: unknown;
/** Override isError flag */
isError?: boolean;
}
/**
* Return type for before_agent_start event handlers.
* Allows hooks to inject context before the agent runs.
*/
export interface BeforeAgentStartEventResult {
/** Message to inject into context (persisted to session, visible in TUI) */
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
/** Text to append to the system prompt for this agent run */
systemPromptAppend?: string;
}
/** Return type for session_before_switch handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint hook that restores code state separately.
* The hook handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` nothing happens, user stays in current session
* - `skipConversationRestore: true` branch happens, but messages stay as-is
* - neither branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
}
/** Return type for session_before_compact handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for session_before_tree handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================
// Hook API
// ============================================================================
/**
* Handler function type for each event.
* Handlers can return R, undefined, or void (bare return statements).
*/
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
expanded: boolean;
}
/**
* Renderer for hook messages.
* Hooks register these to provide custom TUI rendering for their message types.
*/
export type HookMessageRenderer<T = unknown> = (
message: HookMessage<T>,
options: HookMessageRenderOptions,
theme: Theme,
) => Component | undefined;
/**
* Command registration options.
*/
export interface RegisteredCommand {
name: string;
description?: string;
handler: (args: string, ctx: HookCommandContext) => Promise<void>;
}
/**
* HookAPI passed to hook factory functions.
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
*/
export interface HookAPI {
// Session events
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
on(
event: "session_before_compact",
handler: HookHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
// Context and agent events
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
on(event: "before_agent_start", handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
/**
* 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 options.triggerTurn - If true and agent is idle, triggers a new LLM turn.
* Required for async patterns where you want the agent to respond.
* If agent is streaming, message is queued and triggerTurn is ignored.
* @param options.deliverAs - How to deliver the message. Default: "steer".
* - "steer": (streaming) Interrupt mid-run, delivered after current tool execution.
* - "followUp": (streaming) Wait until agent finishes all work before delivery.
* - "nextTurn": (idle) Queue to be included with the next user message as context.
* The message becomes an "aside" - context for the next turn without
* triggering a turn or appearing as a standalone entry.
*/
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): 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 nothing to use the default renderer.
*/
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
/**
* Register a custom slash command.
* Handler receives HookCommandContext with session control methods.
*/
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/**
* Get the list of currently active tool names.
* @returns Array of tool names (e.g., ["read", "bash", "edit", "write"])
*/
getActiveTools(): string[];
/**
* Get all configured tools (built-in via --tools or default, plus custom tools).
* @returns Array of all tool names
*/
getAllTools(): string[];
/**
* Set the active tools by name.
* Both built-in and custom tools can be enabled/disabled.
* Changes take effect on the next agent turn.
* Note: This will invalidate prompt caching for the next request.
*
* @param toolNames - Array of tool names to enable (e.g., ["read", "bash", "grep", "find", "ls"])
*
* @example
* // Switch to read-only mode (plan mode)
* pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
*
* // Restore full access
* pi.setActiveTools(["read", "bash", "edit", "write"]);
*/
setActiveTools(toolNames: string[]): void;
/**
* Register a CLI flag for this hook.
* Flags are parsed from command line and values accessible via getFlag().
*
* @param name - Flag name (will be --name on CLI)
* @param options - Flag configuration
*
* @example
* pi.registerFlag("plan", {
* description: "Start in plan mode (read-only)",
* type: "boolean",
* });
*/
registerFlag(
name: string,
options: {
/** Description shown in --help */
description?: string;
/** Flag type: boolean (--flag) or string (--flag value) */
type: "boolean" | "string";
/** Default value */
default?: boolean | string;
},
): void;
/**
* Get the value of a CLI flag registered by this hook.
* Returns undefined if flag was not provided and has no default.
*
* @param name - Flag name (without --)
* @returns Flag value, or undefined
*
* @example
* if (pi.getFlag("plan")) {
* // plan mode enabled
* }
*/
getFlag(name: string): boolean | string | undefined;
/**
* Register a keyboard shortcut for this hook.
* The handler is called when the shortcut is pressed in interactive mode.
*
* @param shortcut - Key identifier (e.g., Key.shift("p"), "ctrl+x")
* @param options - Shortcut configuration
*
* @example
* import { Key } from "@mariozechner/pi-tui";
*
* pi.registerShortcut(Key.shift("p"), {
* description: "Toggle plan mode",
* handler: async (ctx) => {
* // toggle plan mode
* },
* });
*/
registerShortcut(
shortcut: KeyId,
options: {
/** Description shown in help */
description?: string;
/** Handler called when shortcut is pressed */
handler: (ctx: HookContext) => Promise<void> | void;
},
): void;
/**
* Shared event bus for tool/hook communication.
* Tools can emit events, hooks can listen for them.
*
* @example
* // Hook listening for events
* pi.events.on("subagent:complete", (data) => {
* pi.sendMessage({ customType: "notify", content: `Done: ${data.summary}` });
* });
*
* // Tool emitting events (in custom tool)
* pi.events.emit("my:event", { status: "complete" });
*/
events: EventBus;
}
/**
* Hook factory function type.
* Hooks export a default function that receives the HookAPI.
*/
export type HookFactory = (pi: HookAPI) => void;
// ============================================================================
// Errors
// ============================================================================
/**
* Error emitted when a hook fails.
*/
export interface HookError {
hookPath: string;
event: string;
error: string;
stack?: string;
}

View file

@ -13,26 +13,49 @@ export {
} from "./agent-session.js";
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export {
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
type CustomToolsLoadResult,
type CustomToolUIContext,
discoverAndLoadCustomTools,
type ExecResult,
type LoadedCustomTool,
loadCustomTools,
type RenderResultOptions,
} from "./custom-tools/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
// Extensions system
export {
type HookAPI,
type HookContext,
type HookError,
type HookEvent,
type HookFactory,
HookRunner,
type HookUIContext,
loadHooks,
} from "./hooks/index.js";
type AgentEndEvent,
type AgentStartEvent,
type AgentToolResult,
type AgentToolUpdateCallback,
type BeforeAgentStartEvent,
type ContextEvent,
discoverAndLoadExtensions,
type ExecOptions,
type ExecResult,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
type ExtensionError,
type ExtensionEvent,
type ExtensionFactory,
type ExtensionFlag,
type ExtensionHandler,
ExtensionRunner,
type ExtensionShortcut,
type ExtensionUIContext,
type LoadExtensionsResult,
type LoadedExtension,
type MessageRenderer,
type RegisteredCommand,
type SessionBeforeBranchEvent,
type SessionBeforeCompactEvent,
type SessionBeforeSwitchEvent,
type SessionBeforeTreeEvent,
type SessionBranchEvent,
type SessionCompactEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SessionSwitchEvent,
type SessionTreeEvent,
type ToolCallEvent,
type ToolDefinition,
type ToolRenderResultOptions,
type ToolResultEvent,
type TurnEndEvent,
type TurnStartEvent,
wrapToolsWithExtensions,
} from "./extensions/index.js";

View file

@ -40,11 +40,11 @@ export interface BashExecutionMessage {
}
/**
* Message type for hook-injected messages via sendMessage().
* These are custom messages that hooks can inject into the conversation.
* Message type for extension-injected messages via sendMessage().
* These are custom messages that extensions can inject into the conversation.
*/
export interface HookMessage<T = unknown> {
role: "hookMessage";
export interface CustomMessage<T = unknown> {
role: "custom";
customType: string;
content: string | (TextContent | ImageContent)[];
display: boolean;
@ -70,7 +70,7 @@ export interface CompactionSummaryMessage {
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
hookMessage: HookMessage;
custom: CustomMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
@ -120,15 +120,15 @@ export function createCompactionSummaryMessage(
}
/** Convert CustomMessageEntry to AgentMessage format */
export function createHookMessage(
export function createCustomMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details: unknown | undefined,
timestamp: string,
): HookMessage {
): CustomMessage {
return {
role: "hookMessage",
role: "custom",
customType,
content,
display,
@ -143,7 +143,7 @@ export function createHookMessage(
* This is used by:
* - Agent's transormToLlm option (for prompt calls and queued messages)
* - Compaction's generateSummary (for summarization)
* - Custom hooks and tools
* - Custom extensions and tools
*/
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
@ -159,7 +159,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
case "hookMessage": {
case "custom": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return {
role: "user",

View file

@ -1,11 +1,11 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { CONFIG_DIR_NAME, getCommandsDir } from "../config.js";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
/**
* Represents a custom slash command loaded from a file
* Represents a prompt template loaded from a markdown file
*/
export interface FileSlashCommand {
export interface PromptTemplate {
name: string;
description: string;
content: string;
@ -80,7 +80,7 @@ export function parseCommandArgs(argsString: string): string[] {
}
/**
* Substitute argument placeholders in command content
* Substitute argument placeholders in template content
* Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
*
* Note: Replacement happens on the template string only. Argument values
@ -109,13 +109,13 @@ export function substituteArgs(content: string, args: string[]): string {
}
/**
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
*/
function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] {
const commands: FileSlashCommand[] = [];
function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] {
const templates: PromptTemplate[] = [];
if (!existsSync(dir)) {
return commands;
return templates;
}
try {
@ -127,7 +127,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
if (entry.isDirectory()) {
// Recurse into subdirectory
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
commands.push(...loadCommandsFromDir(fullPath, source, newSubdir));
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir));
} else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
try {
const rawContent = readFileSync(fullPath, "utf-8");
@ -157,7 +157,7 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
// Append source to description
description = description ? `${description} ${sourceStr}` : sourceStr;
commands.push({
templates.push({
name,
description,
content,
@ -172,54 +172,54 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st
// Silently skip directories that can't be read
}
return commands;
return templates;
}
export interface LoadSlashCommandsOptions {
/** Working directory for project-local commands. Default: process.cwd() */
export interface LoadPromptTemplatesOptions {
/** Working directory for project-local templates. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global commands. Default: from getCommandsDir() */
/** Agent config directory for global templates. Default: from getPromptsDir() */
agentDir?: string;
}
/**
* Load all custom slash commands from:
* 1. Global: agentDir/commands/
* 2. Project: cwd/{CONFIG_DIR_NAME}/commands/
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
*/
export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getCommandsDir();
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
const commands: FileSlashCommand[] = [];
const templates: PromptTemplate[] = [];
// 1. Load global commands from agentDir/commands/
// Note: if agentDir is provided, it should be the agent dir, not the commands dir
const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
commands.push(...loadCommandsFromDir(globalCommandsDir, "user"));
// 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
// 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
commands.push(...loadCommandsFromDir(projectCommandsDir, "project"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
return commands;
return templates;
}
/**
* Expand a slash command if it matches a file-based command.
* Returns the expanded content or the original text if not a slash command.
* Expand a prompt template if it matches a template name.
* Returns the expanded content or the original text if not a template.
*/
export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string {
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
if (!text.startsWith("/")) return text;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const fileCommand = fileCommands.find((cmd) => cmd.name === commandName);
if (fileCommand) {
const template = templates.find((t) => t.name === templateName);
if (template) {
const args = parseCommandArgs(argsString);
return substituteArgs(fileCommand.content, args);
return substituteArgs(template.content, args);
}
return text;

View file

@ -9,20 +9,11 @@
* // Minimal - everything auto-discovered
* const session = await createAgentSession();
*
* // With custom hooks
* const session = await createAgentSession({
* hooks: [
* ...await discoverHooks(),
* { factory: myHookFactory },
* ],
* });
*
* // Full control
* const session = await createAgentSession({
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* tools: [readTool, bashTool],
* hooks: [],
* skills: [],
* sessionFile: false,
* });
@ -31,27 +22,25 @@
import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import {
type CustomToolsLoadResult,
discoverAndLoadCustomTools,
type LoadedCustomTool,
wrapCustomTools,
} from "./custom-tools/index.js";
import type { CustomTool } from "./custom-tools/types.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
import type { HookFactory } from "./hooks/types.js";
import {
discoverAndLoadExtensions,
ExtensionRunner,
type LoadExtensionsResult,
type LoadedExtension,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js";
import { SessionManager } from "./session-manager.js";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
import { loadSkills as loadSkillsInternal, type Skill } from "./skills.js";
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands.js";
import {
buildSystemPrompt as buildSystemPromptInternal,
loadProjectContextFiles as loadContextFilesInternal,
@ -107,27 +96,20 @@ export interface CreateAgentSessionOptions {
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
/** Additional extension paths to load (merged with discovery). */
additionalExtensionPaths?: string[];
/** Pre-loaded extensions (skips loading, used when extensions were loaded early for CLI flags). */
preloadedExtensions?: LoadedExtension[];
/** Hooks (replaces discovery). */
hooks?: Array<{ path?: string; factory: HookFactory }>;
/** Additional hook paths to load (merged with discovery). */
additionalHookPaths?: string[];
/** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */
preloadedHooks?: LoadedHook[];
/** Shared event bus for tool/hook communication. Default: creates new bus. */
/** Shared event bus for tool/extension communication. Default: creates new bus. */
eventBus?: EventBus;
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
contextFiles?: Array<{ path: string; content: string }>;
/** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */
slashCommands?: FileSlashCommand[];
/** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */
promptTemplates?: PromptTemplate[];
/** Session manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager;
@ -140,19 +122,18 @@ export interface CreateAgentSessionOptions {
export interface CreateAgentSessionResult {
/** The created session */
session: AgentSession;
/** Custom tools result (for UI context setup in interactive mode) */
customToolsResult: CustomToolsLoadResult;
/** Extensions result (for UI context setup in interactive mode) */
extensionsResult: LoadExtensionsResult;
/** Warning if session was restored with a different model than saved */
modelFallbackMessage?: string;
}
// Re-exports
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js";
export type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "./extensions/index.js";
export type { PromptTemplate } from "./prompt-templates.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { FileSlashCommand } from "./slash-commands.js";
export type { Tool } from "./tools/index.js";
export {
@ -202,63 +183,27 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD
}
/**
* Discover hooks from cwd and agentDir.
* @param eventBus - Shared event bus for pi.events communication. Pass to createAgentSession too.
* Discover extensions from cwd and agentDir.
* @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too.
* @param cwd - Current working directory
* @param agentDir - Agent configuration directory
*/
export async function discoverHooks(
export async function discoverExtensions(
eventBus: EventBus,
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; factory: HookFactory }>> {
): Promise<LoadExtensionsResult> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir, eventBus);
const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus);
// Log errors but don't fail
for (const { path, error } of errors) {
console.error(`Failed to load hook "${path}": ${error}`);
for (const { path, error } of result.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
return hooks.map((h) => ({
path: h.path,
factory: createFactoryFromLoadedHook(h),
}));
}
/**
* Discover custom tools from cwd and agentDir.
* @param eventBus - Shared event bus for tool.events communication. Pass to createAgentSession too.
* @param cwd - Current working directory
* @param agentDir - Agent configuration directory
*/
export async function discoverCustomTools(
eventBus: EventBus,
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; tool: CustomTool }>> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
const { tools, errors } = await discoverAndLoadCustomTools(
[],
resolvedCwd,
Object.keys(allTools),
resolvedAgentDir,
eventBus,
);
// Log errors but don't fail
for (const { path, error } of errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
return tools.map((t) => ({
path: t.path,
tool: t.tool,
}));
return result;
}
/**
@ -284,10 +229,10 @@ export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ p
}
/**
* Discover slash commands from cwd and agentDir.
* Discover prompt templates from cwd and agentDir.
*/
export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlashCommand[] {
return loadSlashCommandsInternal({
export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] {
return loadPromptTemplatesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
@ -336,139 +281,12 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
hideThinkingBlock: manager.getHideThinkingBlock(),
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
customTools: manager.getCustomToolPaths(),
extensions: manager.getExtensionPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
};
}
// Internal Helpers
/**
* Create a HookFactory from a LoadedHook.
* This allows mixing discovered hooks with inline hooks.
*/
function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
return (api) => {
for (const [eventType, handlers] of loaded.handlers) {
for (const handler of handlers) {
api.on(eventType as any, handler as any);
}
}
};
}
/**
* Convert hook definitions to LoadedHooks for the HookRunner.
*/
function createLoadedHooksFromDefinitions(
definitions: Array<{ path?: string; factory: HookFactory }>,
eventBus: EventBus,
): LoadedHook[] {
return definitions.map((def) => {
const hookPath = def.path ?? "<inline>";
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
const flags = new Map<string, any>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<KeyId, any>();
let sendMessageHandler: (
message: any,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
let getActiveToolsHandler: () => string[] = () => [];
let getAllToolsHandler: () => string[] = () => [];
let setActiveToolsHandler: (toolNames: string[]) => void = () => {};
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
cancelled: false,
});
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
sendMessage: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => {
sendMessageHandler(message, options);
},
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
},
registerMessageRenderer: (customType: string, renderer: any) => {
messageRenderers.set(customType, renderer);
},
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
registerFlag: (name: string, options: any) => {
flags.set(name, { name, hookPath, ...options });
if (options.default !== undefined) {
flagValues.set(name, options.default);
}
},
getFlag: (name: string) => flagValues.get(name),
registerShortcut: (shortcut: KeyId, options: any) => {
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
},
newSession: (options?: any) => newSessionHandler(options),
branch: (entryId: string) => branchHandler(entryId),
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
getActiveTools: () => getActiveToolsHandler(),
getAllTools: () => getAllToolsHandler(),
setActiveTools: (toolNames: string[]) => setActiveToolsHandler(toolNames),
events: eventBus,
};
def.factory(api as any);
return {
path: hookPath,
resolvedPath: hookPath,
handlers,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler: (
handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void,
) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => {
newSessionHandler = handler;
},
setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => {
branchHandler = handler;
},
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
navigateTreeHandler = handler;
},
setGetActiveToolsHandler: (handler: () => string[]) => {
getActiveToolsHandler = handler;
},
setGetAllToolsHandler: (handler: () => string[]) => {
getAllToolsHandler = handler;
},
setSetActiveToolsHandler: (handler: (toolNames: string[]) => void) => {
setActiveToolsHandler = handler;
},
setFlagValue: (name: string, value: boolean | string) => {
flagValues.set(name, value);
},
};
});
}
// Factory
/**
@ -497,7 +315,6 @@ function createLoadedHooksFromDefinitions(
* getApiKey: async () => process.env.MY_KEY,
* systemPrompt: 'You are helpful.',
* tools: [readTool, bashTool],
* hooks: [],
* skills: [],
* sessionManager: SessionManager.inMemory(),
* });
@ -592,7 +409,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
time("discoverContextFiles");
const autoResizeImages = settingsManager.getImageAutoResize();
// Create ALL built-in tools for the registry (hooks can enable any of them)
// Create ALL built-in tools for the registry (extensions can enable any of them)
const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } });
// Determine initially active built-in tools (default: read, bash, edit, write)
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
@ -602,62 +419,54 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
time("createAllTools");
let customToolsResult: CustomToolsLoadResult;
if (options.customTools !== undefined) {
// Use provided custom tools
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
path: ct.path ?? "<inline>",
resolvedPath: ct.path ?? "<inline>",
tool: ct.tool,
}));
customToolsResult = {
tools: loadedTools,
// Load extensions (discovers from standard locations + configured paths)
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) {
// Use pre-loaded extensions (from early CLI flag discovery)
extensionsResult = {
extensions: options.preloadedExtensions,
errors: [],
setUIContext: () => {},
setSendMessageHandler: () => {},
};
} else {
// Discover custom tools, merging with additional paths
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
customToolsResult = await discoverAndLoadCustomTools(
configuredPaths,
cwd,
Object.keys(allTools),
agentDir,
eventBus,
);
time("discoverAndLoadCustomTools");
for (const { path, error } of customToolsResult.errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
// Discover extensions, merging with additional paths
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];
extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus);
time("discoverAndLoadExtensions");
for (const { path, error } of extensionsResult.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
}
let hookRunner: HookRunner | undefined;
if (options.preloadedHooks !== undefined && options.preloadedHooks.length > 0) {
// Use pre-loaded hooks (from early CLI flag discovery)
hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry);
} else if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks, eventBus);
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
}
} else {
// Discover hooks, merging with additional paths
const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])];
const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir, eventBus);
time("discoverAndLoadHooks");
for (const { path, error } of errors) {
console.error(`Failed to load hook "${path}": ${error}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
}
// Create extension runner if we have extensions
let extensionRunner: ExtensionRunner | undefined;
if (extensionsResult.extensions.length > 0) {
extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
}
// Wrap custom tools with context getter (agent/session assigned below, accessed at execute time)
// Wrap extension-registered tools with context getter (agent/session assigned below, accessed at execute time)
let agent: Agent;
let session: AgentSession;
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
const wrappedExtensionTools = wrapRegisteredTools(registeredTools, () => ({
ui: extensionRunner?.getUIContext() ?? {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return {} as any;
},
},
hasUI: extensionRunner?.getHasUI() ?? false,
cwd,
sessionManager,
modelRegistry,
model: agent.state.model,
@ -668,27 +477,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
}));
// Create tool registry mapping name -> tool (for hook getTools/setTools)
// Registry contains ALL built-in tools so hooks can enable any of them
// Create tool registry mapping name -> tool (for extension getTools/setTools)
// Registry contains ALL built-in tools so extensions can enable any of them
const toolRegistry = new Map<string, AgentTool>();
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
toolRegistry.set(name, tool as AgentTool);
}
for (const tool of wrappedCustomTools as AgentTool[]) {
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
// Initially active tools = active built-in + custom
let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedCustomTools];
// Initially active tools = active built-in + extension tools
let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedExtensionTools];
time("combineTools");
// Wrap tools with hooks if available
// Wrap tools with extensions if available
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
if (hookRunner) {
activeToolsArray = wrapToolsWithHooks(activeToolsArray as AgentTool[], hookRunner);
// Wrap ALL registry tools (not just active) so hooks can enable any
if (extensionRunner) {
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
// Wrap ALL registry tools (not just active) so extensions can enable any
const allRegistryTools = Array.from(toolRegistry.values());
const wrappedAllTools = wrapToolsWithHooks(allRegistryTools, hookRunner);
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
wrappedToolRegistry = new Map<string, AgentTool>();
for (const tool of wrappedAllTools) {
wrappedToolRegistry.set(tool.name, tool);
@ -727,8 +536,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
time("buildSystemPrompt");
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
time("discoverSlashCommands");
const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir);
time("discoverPromptTemplates");
agent = new Agent({
initialState: {
@ -738,9 +547,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
tools: activeToolsArray,
},
convertToLlm,
transformContext: hookRunner
transformContext: extensionRunner
? async (messages) => {
return hookRunner.emitContext(messages);
return extensionRunner.emitContext(messages);
}
: undefined,
steeringMode: settingsManager.getSteeringMode(),
@ -775,9 +584,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
sessionManager,
settingsManager,
scopedModels: options.scopedModels,
fileCommands: slashCommands,
hookRunner,
customTools: customToolsResult.tools,
promptTemplates: promptTemplates,
extensionRunner,
skillsSettings: settingsManager.getSkillsSettings(),
modelRegistry,
toolRegistry: wrappedToolRegistry ?? toolRegistry,
@ -785,14 +593,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
time("createAgentSession");
// Wire up sendMessage for custom tools
customToolsResult.setSendMessageHandler((msg, opts) => {
session.sendHookMessage(msg, opts);
});
return {
session,
customToolsResult,
extensionsResult,
modelFallbackMessage,
};
}

View file

@ -17,13 +17,13 @@ import { join, resolve } from "path";
import { getAgentDir as getDefaultAgentDir } from "../config.js";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
type HookMessage,
createCustomMessage,
} from "./messages.js";
export const CURRENT_SESSION_VERSION = 2;
export const CURRENT_SESSION_VERSION = 3;
export interface SessionHeader {
type: "session";
@ -66,9 +66,9 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
fromHook?: boolean;
}
@ -76,17 +76,17 @@ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
type: "branch_summary";
fromId: string;
summary: string;
/** Hook-specific data (not sent to LLM) */
/** Extension-specific data (not sent to LLM) */
details?: T;
/** True if generated by a hook, false if pi-generated */
/** True if generated by an extension, false if pi-generated */
fromHook?: boolean;
}
/**
* Custom entry for hooks to store hook-specific data in the session.
* Use customType to identify your hook's entries.
* Custom entry for extensions to store extension-specific data in the session.
* Use customType to identify your extension's entries.
*
* Purpose: Persist hook state across session reloads. On reload, hooks can
* Purpose: Persist extension state across session reloads. On reload, extensions can
* scan entries for their customType and reconstruct internal state.
*
* Does NOT participate in LLM context (ignored by buildSessionContext).
@ -106,12 +106,12 @@ export interface LabelEntry extends SessionEntryBase {
}
/**
* Custom message entry for hooks to inject messages into LLM context.
* Use customType to identify your hook's entries.
* Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries.
*
* Unlike CustomEntry, this DOES participate in LLM context.
* The content is converted to a user message in buildSessionContext().
* Use details for hook-specific metadata (not sent to LLM).
* Use details for extension-specific metadata (not sent to LLM).
*
* display controls TUI rendering:
* - false: hidden entirely
@ -218,8 +218,23 @@ function migrateV1ToV2(entries: FileEntry[]): void {
}
}
// Add future migrations here:
// function migrateV2ToV3(entries: FileEntry[]): void { ... }
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
function migrateV2ToV3(entries: FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "session") {
entry.version = 3;
continue;
}
// Update message entries with hookMessage role
if (entry.type === "message") {
const msgEntry = entry as SessionMessageEntry;
if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {
(msgEntry.message as { role: string }).role = "custom";
}
}
}
}
/**
* Run all necessary migrations to bring entries to current version.
@ -232,7 +247,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
if (version >= CURRENT_SESSION_VERSION) return false;
if (version < 2) migrateV1ToV2(entries);
// if (version < 3) migrateV2ToV3(entries);
if (version < 3) migrateV2ToV3(entries);
return true;
}
@ -342,7 +357,7 @@ export function buildSessionContext(
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
@ -609,7 +624,7 @@ export class SessionManager {
* so it is easier to find them.
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
*/
appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.byId),
@ -671,7 +686,7 @@ export class SessionManager {
return entry.id;
}
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
appendCustomEntry(customType: string, data?: unknown): string {
const entry: CustomEntry = {
type: "custom",
@ -686,11 +701,11 @@ export class SessionManager {
}
/**
* Append a custom message entry (for hooks) that participates in LLM context.
* @param customType Hook identifier for filtering on reload
* Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Extension identifier for filtering on reload
* @param content Message content (string or TextContent/ImageContent array)
* @param display Whether to show in TUI (true = styled display, false = hidden)
* @param details Optional hook-specific metadata (not sent to LLM)
* @param details Optional extension-specific metadata (not sent to LLM)
* @returns Entry id
*/
appendCustomMessageEntry<T = unknown>(

View file

@ -52,8 +52,7 @@ export interface Settings {
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
customTools?: string[]; // Array of custom tool file paths
extensions?: string[]; // Array of extension file paths
skills?: SkillsSettings;
terminal?: TerminalSettings;
images?: ImageSettings;
@ -340,21 +339,12 @@ export class SettingsManager {
this.save();
}
getHookPaths(): string[] {
return [...(this.settings.hooks ?? [])];
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
setHookPaths(paths: string[]): void {
this.globalSettings.hooks = paths;
this.save();
}
getCustomToolPaths(): string[] {
return [...(this.settings.customTools ?? [])];
}
setCustomToolPaths(paths: string[]): void {
this.globalSettings.customTools = paths;
setExtensionPaths(paths: string[]): void {
this.globalSettings.extensions = paths;
this.save();
}

View file

@ -274,14 +274,16 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
Available tools:
${toolsList}
In addition to the tools above, you may have access to other custom tools depending on the project.
Guidelines:
${guidelines}
Documentation:
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (hooks, custom tools, SDK)
- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md)
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When asked to create: custom models/providers (README.md), extensions (docs/extensions.md, examples/extensions/), themes (docs/theme.md), skills (docs/skills.md)
- Always read the doc, examples, AND follow .md cross-references before implementing`;
if (appendSection) {