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

@ -26,8 +26,7 @@ export interface Args {
sessionDir?: string;
models?: string[];
tools?: ToolName[];
hooks?: string[];
customTools?: string[];
extensions?: string[];
print?: boolean;
export?: string;
noSkills?: boolean;
@ -35,7 +34,7 @@ export interface Args {
listModels?: string | true;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially hook flags) - map of flag name to value */
/** Unknown flags (potentially extension flags) - map of flag name to value */
unknownFlags: Map<string, boolean | string>;
}
@ -45,7 +44,7 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}
export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boolean" | "string" }>): Args {
export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
const result: Args = {
messages: [],
fileArgs: [],
@ -114,12 +113,9 @@ export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boole
result.print = true;
} else if (arg === "--export" && i + 1 < args.length) {
result.export = args[++i];
} else if (arg === "--hook" && i + 1 < args.length) {
result.hooks = result.hooks ?? [];
result.hooks.push(args[++i]);
} else if (arg === "--tool" && i + 1 < args.length) {
result.customTools = result.customTools ?? [];
result.customTools.push(args[++i]);
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
result.extensions = result.extensions ?? [];
result.extensions.push(args[++i]);
} else if (arg === "--no-skills") {
result.noSkills = true;
} else if (arg === "--skills" && i + 1 < args.length) {
@ -134,18 +130,18 @@ export function parseArgs(args: string[], hookFlags?: Map<string, { type: "boole
}
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (arg.startsWith("--") && hookFlags) {
// Check if it's a hook-registered flag
} else if (arg.startsWith("--") && extensionFlags) {
// Check if it's an extension-registered flag
const flagName = arg.slice(2);
const hookFlag = hookFlags.get(flagName);
if (hookFlag) {
if (hookFlag.type === "boolean") {
const extFlag = extensionFlags.get(flagName);
if (extFlag) {
if (extFlag.type === "boolean") {
result.unknownFlags.set(flagName, true);
} else if (hookFlag.type === "string" && i + 1 < args.length) {
} else if (extFlag.type === "string" && i + 1 < args.length) {
result.unknownFlags.set(flagName, args[++i]);
}
}
// Unknown flags without hookFlags are silently ignored (first pass)
// Unknown flags without extensionFlags are silently ignored (first pass)
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
@ -178,8 +174,7 @@ ${chalk.bold("Options:")}
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--hook <path> Load a hook file (can be used multiple times)
--tool <path> Load a custom tool file (can be used multiple times)
--extension, -e <path> Load an extension file (can be used multiple times)
--no-skills Disable skills discovery and loading
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
--export <file> Export session file to HTML and exit
@ -187,7 +182,7 @@ ${chalk.bold("Options:")}
--help, -h Show this help
--version, -v Show version number
Hooks can register additional flags (e.g., --plan from plan-mode hook).
Extensions can register additional flags (e.g., --plan from plan-mode extension).
${chalk.bold("Examples:")}
# Interactive mode

View file

@ -147,9 +147,9 @@ export function getToolsDir(): string {
return join(getAgentDir(), "tools");
}
/** Get path to slash commands directory */
export function getCommandsDir(): string {
return join(getAgentDir(), "commands");
/** Get path to prompt templates directory */
export function getPromptsDir(): string {
return join(getAgentDir(), "prompts");
}
/** Get path to sessions directory */

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) {

View file

@ -33,25 +33,53 @@ export {
serializeConversation,
shouldCompact,
} from "./core/compaction/index.js";
// Custom tools
export type {
AgentToolUpdateCallback,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js";
export type * from "./core/hooks/index.js";
// Hook system types and type guards
// Extension system
export type {
AgentEndEvent,
AgentStartEvent,
AgentToolResult,
AgentToolUpdateCallback,
BeforeAgentStartEvent,
ContextEvent,
ExecOptions,
ExecResult,
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
ExtensionError,
ExtensionEvent,
ExtensionFactory,
ExtensionFlag,
ExtensionHandler,
ExtensionShortcut,
ExtensionUIContext,
LoadExtensionsResult,
LoadedExtension,
MessageRenderer,
MessageRenderOptions,
RegisteredCommand,
RegisteredTool,
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
ToolCallEvent,
ToolDefinition,
ToolRenderResultOptions,
ToolResultEvent,
TurnEndEvent,
TurnStartEvent,
} from "./core/extensions/index.js";
export {
discoverAndLoadExtensions,
ExtensionRunner,
isBashToolResult,
isEditToolResult,
isFindToolResult,
@ -59,7 +87,12 @@ export {
isLsToolResult,
isReadToolResult,
isWriteToolResult,
} from "./core/hooks/index.js";
loadExtensions,
wrapRegisteredTool,
wrapRegisteredTools,
wrapToolsWithExtensions,
wrapToolWithExtensions,
} from "./core/extensions/index.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
// SDK for programmatic usage
@ -83,17 +116,12 @@ export {
// Discovery
discoverAuthStorage,
discoverContextFiles,
discoverCustomTools,
discoverHooks,
discoverExtensions,
discoverModels,
discoverPromptTemplates,
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
type PromptTemplate,
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js";
@ -159,9 +187,9 @@ export {
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";
// UI components for hooks
// UI components for extensions
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
// Theme utilities for custom tools and hooks
// Theme utilities for custom tools and extensions
export {
getMarkdownTheme,
getSelectListTheme,

View file

@ -16,11 +16,9 @@ import { selectSession } from "./cli/session-picker.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import type { AgentSession } from "./core/agent-session.js";
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
import { createEventBus } from "./core/event-bus.js";
import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadHooks } from "./core/hooks/index.js";
import type { HookUIContext } from "./core/index.js";
import { discoverAndLoadExtensions, type ExtensionUIContext, type LoadedExtension } from "./core/extensions/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
@ -62,13 +60,13 @@ async function runInteractiveMode(
migratedProviders: string[],
versionCheckPromise: Promise<string | undefined>,
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
extensions: LoadedExtension[],
setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
const mode = new InteractiveMode(session, version, changelogMarkdown, extensions, setExtensionUIContext, fdPath);
await mode.init();
@ -214,7 +212,7 @@ function buildSessionOptions(
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
preloadedHooks?: import("./core/hooks/index.js").LoadedHook[],
preloadedExtensions?: LoadedExtension[],
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@ -273,14 +271,9 @@ function buildSessionOptions(
options.skills = [];
}
// Pre-loaded hooks (from early CLI flag discovery)
if (preloadedHooks && preloadedHooks.length > 0) {
options.preloadedHooks = preloadedHooks;
}
// Additional custom tool paths from CLI
if (parsed.customTools && parsed.customTools.length > 0) {
options.additionalCustomToolPaths = parsed.customTools;
// Pre-loaded extensions (from early CLI flag discovery)
if (preloadedExtensions && preloadedExtensions.length > 0) {
options.preloadedExtensions = preloadedExtensions;
}
return options;
@ -297,35 +290,35 @@ export async function main(args: string[]) {
const modelRegistry = discoverModels(authStorage);
time("discoverModels");
// First pass: parse args to get --hook paths
// First pass: parse args to get --extension paths
const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load hooks to discover their CLI flags
// Early load extensions to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const eventBus = createEventBus();
const hookPaths = firstPass.hooks ?? [];
const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir, eventBus);
time("discoverHookFlags");
const extensionPaths = firstPass.extensions ?? [];
const { extensions: loadedExtensions } = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
time("discoverExtensionFlags");
// Collect all hook flags
const hookFlags = new Map<string, { type: "boolean" | "string" }>();
for (const hook of loadedHooks) {
for (const [name, flag] of hook.flags) {
hookFlags.set(name, { type: flag.type });
// Collect all extension flags
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of loadedExtensions) {
for (const [name, flag] of ext.flags) {
extensionFlags.set(name, { type: flag.type });
}
}
// Second pass: parse args with hook flags
const parsed = parseArgs(args, hookFlags);
// Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags);
time("parseArgs");
// Pass flag values to hooks
// Pass flag values to extensions
for (const [name, value] of parsed.unknownFlags) {
for (const hook of loadedHooks) {
if (hook.flags.has(name)) {
hook.setFlagValue(name, value);
for (const ext of loadedExtensions) {
if (ext.flags.has(name)) {
ext.setFlagValue(name, value);
}
}
}
@ -401,7 +394,7 @@ export async function main(args: string[]) {
sessionManager = SessionManager.open(selectedPath);
}
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks);
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedExtensions);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.eventBus = eventBus;
@ -416,7 +409,7 @@ export async function main(args: string[]) {
}
time("buildSessionOptions");
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) {
@ -469,8 +462,8 @@ export async function main(args: string[]) {
migratedProviders,
versionCheckPromise,
parsed.messages,
customToolsResult.tools,
customToolsResult.setUIContext,
extensionsResult.extensions,
extensionsResult.setUIContext,
initialMessage,
initialImages,
fdPath,

View file

@ -2,7 +2,7 @@ import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozech
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Loader wrapped with borders for hook UI */
/** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;

View file

@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a branch summary message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
* Uses same background color as custom messages for visual consistency.
*/
export class BranchSummaryMessageComponent extends Box {
private expanded = false;

View file

@ -4,7 +4,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
* Uses same background color as custom messages for visual consistency.
*/
export class CompactionSummaryMessageComponent extends Box {
private expanded = false;

View file

@ -12,8 +12,8 @@ export class CustomEditor extends Editor {
public onEscape?: () => void;
public onCtrlD?: () => void;
public onPasteImage?: () => void;
/** Handler for hook-registered shortcuts. Returns true if handled. */
public onHookShortcut?: (data: string) => boolean;
/** Handler for extension-registered shortcuts. Returns true if handled. */
public onExtensionShortcut?: (data: string) => boolean;
constructor(theme: EditorTheme, keybindings: KeybindingsManager) {
super(theme);
@ -28,8 +28,8 @@ export class CustomEditor extends Editor {
}
handleInput(data: string): void {
// Check hook-registered shortcuts first
if (this.onHookShortcut?.(data)) {
// Check extension-registered shortcuts first
if (this.onExtensionShortcut?.(data)) {
return;
}

View file

@ -1,22 +1,22 @@
import type { TextContent } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
import type { HookMessage } from "../../../core/messages.js";
import type { MessageRenderer } from "../../../core/extensions/types.js";
import type { CustomMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a custom message entry from hooks.
* Component that renders a custom message entry from extensions.
* Uses distinct styling to differentiate from user messages.
*/
export class HookMessageComponent extends Container {
private message: HookMessage<unknown>;
private customRenderer?: HookMessageRenderer;
export class CustomMessageComponent extends Container {
private message: CustomMessage<unknown>;
private customRenderer?: MessageRenderer;
private box: Box;
private customComponent?: Component;
private _expanded = false;
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
constructor(message: CustomMessage<unknown>, customRenderer?: MessageRenderer) {
super();
this.message = message;
this.customRenderer = customRenderer;

View file

@ -4,9 +4,9 @@ import { theme } from "../theme/theme.js";
/**
* Dynamic border component that adjusts to viewport width.
*
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
* Note: When used from extensions loaded via jiti, the global `theme` may be undefined
* because jiti creates a separate module cache. Always pass an explicit color
* function when using DynamicBorder in components exported for hook use.
* function when using DynamicBorder in components exported for extension use.
*/
export class DynamicBorder implements Component {
private color: (str: string) => string;

View file

@ -1,5 +1,5 @@
/**
* Multi-line editor component for hooks.
* Multi-line editor component for extensions.
* Supports Ctrl+G for external editor.
*/
@ -11,7 +11,7 @@ import { Container, Editor, getEditorKeybindings, matchesKey, Spacer, Text, type
import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookEditorComponent extends Container {
export class ExtensionEditorComponent extends Container {
private editor: Editor;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
@ -91,7 +91,7 @@ export class HookEditorComponent extends Container {
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`);
try {
fs.writeFileSync(tmpFile, currentText, "utf-8");

View file

@ -1,12 +1,12 @@
/**
* Simple text input component for hooks.
* Simple text input component for extensions.
*/
import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookInputComponent extends Container {
export class ExtensionInputComponent extends Container {
private input: Input;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;

View file

@ -1,5 +1,5 @@
/**
* Generic selector component for hooks.
* Generic selector component for extensions.
* Displays a list of string options with keyboard navigation.
*/
@ -7,7 +7,7 @@ import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookSelectorComponent extends Container {
export class ExtensionSelectorComponent extends Container {
private options: string[];
private selectedIndex = 0;
private listContainer: Container;

View file

@ -46,7 +46,7 @@ export class FooterComponent implements Component {
private gitWatcher: FSWatcher | null = null;
private onBranchChange: (() => void) | null = null;
private autoCompactEnabled: boolean = true;
private hookStatuses: Map<string, string> = new Map();
private extensionStatuses: Map<string, string> = new Map();
constructor(session: AgentSession) {
this.session = session;
@ -57,17 +57,17 @@ export class FooterComponent implements Component {
}
/**
* Set hook status text to display in the footer.
* Set extension status text to display in the footer.
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
* ANSI escape codes for styling are preserved.
* @param key - Unique key to identify this status
* @param text - Status text, or undefined to clear
*/
setHookStatus(key: string, text: string | undefined): void {
setExtensionStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.hookStatuses.delete(key);
this.extensionStatuses.delete(key);
} else {
this.hookStatuses.set(key, text);
this.extensionStatuses.set(key, text);
}
}
@ -309,9 +309,9 @@ export class FooterComponent implements Component {
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
// Add hook statuses on a single line, sorted by key alphabetically
if (this.hookStatuses.size > 0) {
const sortedStatuses = Array.from(this.hookStatuses.entries())
// Add extension statuses on a single line, sorted by key alphabetically
if (this.extensionStatuses.size > 0) {
const sortedStatuses = Array.from(this.extensionStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const statusLine = sortedStatuses.join(" ");

View file

@ -11,7 +11,7 @@ import {
type TUI,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomTool } from "../../../core/custom-tools/types.js";
import type { ToolDefinition } from "../../../core/extensions/types.js";
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { convertToPng } from "../../../utils/image-convert.js";
@ -58,7 +58,7 @@ export class ToolExecutionComponent extends Container {
private expanded = false;
private showImages: boolean;
private isPartial = true;
private customTool?: CustomTool;
private toolDefinition?: ToolDefinition;
private ui: TUI;
private cwd: string;
private result?: {
@ -76,7 +76,7 @@ export class ToolExecutionComponent extends Container {
toolName: string,
args: any,
options: ToolExecutionOptions = {},
customTool: CustomTool | undefined,
toolDefinition: ToolDefinition | undefined,
ui: TUI,
cwd: string = process.cwd(),
) {
@ -84,7 +84,7 @@ export class ToolExecutionComponent extends Container {
this.toolName = toolName;
this.args = args;
this.showImages = options.showImages ?? true;
this.customTool = customTool;
this.toolDefinition = toolDefinition;
this.ui = ui;
this.cwd = cwd;
@ -94,7 +94,7 @@ export class ToolExecutionComponent extends Container {
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
if (customTool || toolName === "bash") {
if (toolDefinition || toolName === "bash") {
this.addChild(this.contentBox);
} else {
this.addChild(this.contentText);
@ -214,15 +214,15 @@ export class ToolExecutionComponent extends Container {
: (text: string) => theme.bg("toolSuccessBg", text);
// Check for custom tool rendering
if (this.customTool) {
if (this.toolDefinition) {
// Custom tools use Box for flexible component rendering
this.contentBox.setBgFn(bgFn);
this.contentBox.clear();
// Render call component
if (this.customTool.renderCall) {
if (this.toolDefinition.renderCall) {
try {
const callComponent = this.customTool.renderCall(this.args, theme);
const callComponent = this.toolDefinition.renderCall(this.args, theme);
if (callComponent) {
this.contentBox.addChild(callComponent);
}
@ -236,9 +236,9 @@ export class ToolExecutionComponent extends Container {
}
// Render result component if we have a result
if (this.result && this.customTool.renderResult) {
if (this.result && this.toolDefinition.renderResult) {
try {
const resultComponent = this.customTool.renderResult(
const resultComponent = this.toolDefinition.renderResult(
{ content: this.result.content as any, details: this.result.details },
{ expanded: this.expanded, isPartial: this.isPartial },
theme,

View file

@ -30,8 +30,12 @@ import {
import { exec, spawn, spawnSync } from "child_process";
import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js";
import type { HookContext, HookRunner, HookUIContext } from "../../core/hooks/index.js";
import type {
ExtensionContext,
ExtensionRunner,
ExtensionUIContext,
LoadedExtension,
} from "../../core/extensions/index.js";
import { KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
@ -47,12 +51,12 @@ import { BorderedLoader } from "./components/bordered-loader.js";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
import { CustomEditor } from "./components/custom-editor.js";
import { CustomMessageComponent } from "./components/custom-message.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { ExtensionEditorComponent } from "./components/extension-editor.js";
import { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.js";
import { HookEditorComponent } from "./components/hook-editor.js";
import { HookInputComponent } from "./components/hook-input.js";
import { HookMessageComponent } from "./components/hook-message.js";
import { HookSelectorComponent } from "./components/hook-selector.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
@ -136,18 +140,15 @@ export class InteractiveMode {
private retryLoader: Loader | undefined = undefined;
private retryEscapeHandler?: () => void;
// Hook UI state
private hookSelector: HookSelectorComponent | undefined = undefined;
private hookInput: HookInputComponent | undefined = undefined;
private hookEditor: HookEditorComponent | undefined = undefined;
// Extension UI state
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
private extensionInput: ExtensionInputComponent | undefined = undefined;
private extensionEditor: ExtensionEditorComponent | undefined = undefined;
// Hook widgets (components rendered above the editor)
private hookWidgets = new Map<string, Component & { dispose?(): void }>();
// Extension widgets (components rendered above the editor)
private extensionWidgets = new Map<string, Component & { dispose?(): void }>();
private widgetContainer!: Container;
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -163,14 +164,13 @@ export class InteractiveMode {
session: AgentSession,
version: string,
changelogMarkdown: string | undefined = undefined,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
_extensions: LoadedExtension[] = [],
private setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
fdPath: string | undefined = undefined,
) {
this.session = session;
this.version = version;
this.changelogMarkdown = changelogMarkdown;
this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct]));
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
@ -183,7 +183,7 @@ export class InteractiveMode {
this.footer = new FooterComponent(session);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
// Define slash commands for autocomplete
// Define commands for autocomplete
const slashCommands: SlashCommand[] = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)" },
@ -205,21 +205,23 @@ export class InteractiveMode {
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
// Convert file commands to SlashCommand format
const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({
// Convert prompt templates to SlashCommand format for autocomplete
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
// Convert hook commands to SlashCommand format
const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "(hook command)",
}));
// Convert extension commands to SlashCommand format
const extensionCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(
(cmd) => ({
name: cmd.name,
description: cmd.description ?? "(extension command)",
}),
);
// Setup autocomplete
const autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...fileSlashCommands, ...hookCommands],
[...slashCommands, ...templateCommands, ...extensionCommands],
process.cwd(),
fdPath,
);
@ -348,8 +350,8 @@ export class InteractiveMode {
const cwdBasename = path.basename(process.cwd());
this.ui.terminal.setTitle(`pi - ${cwdBasename}`);
// Initialize hooks with TUI-based UI context
await this.initHooksAndCustomTools();
// Initialize extensions with TUI-based UI context
await this.initExtensions();
// Subscribe to agent events
this.subscribeToAgent();
@ -368,13 +370,13 @@ export class InteractiveMode {
}
// =========================================================================
// Hook System
// Extension System
// =========================================================================
/**
* Initialize the hook system with TUI-based UI context.
* Initialize the extension system with TUI-based UI context.
*/
private async initHooksAndCustomTools(): Promise<void> {
private async initExtensions(): Promise<void> {
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
@ -403,36 +405,21 @@ export class InteractiveMode {
}
}
// Show loaded custom tools
if (this.customTools.size > 0) {
const toolList = Array.from(this.customTools.values())
.map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded custom tools:\n") + toolList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
// Create and set extension UI context
const uiContext = this.createExtensionUIContext();
this.setExtensionUIContext(uiContext, true);
const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) {
return; // No extensions loaded
}
// Create and set hook & tool UI context
const uiContext = this.createHookUIContext();
this.setToolUIContext(uiContext, true);
// Notify custom tools of session start
await this.emitCustomToolSessionEvent({
reason: "start",
previousSessionFile: undefined,
});
const hookRunner = this.session.hookRunner;
if (!hookRunner) {
return; // No hooks loaded
}
hookRunner.initialize({
extensionRunner.initialize({
getModel: () => this.session.model,
sendMessageHandler: (message, options) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendHookMessage(message, options)
.sendCustomMessage(message, options)
.then(() => {
// For non-streaming cases with display=true, update UI
// (streaming cases update via message_end event)
@ -441,7 +428,7 @@ export class InteractiveMode {
}
})
.catch((err) => {
this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
appendEntryHandler: (customType, data) => {
@ -522,71 +509,47 @@ export class InteractiveMode {
hasUI: true,
});
// Subscribe to hook errors
hookRunner.onError((error) => {
this.showHookError(error.hookPath, error.error, error.stack);
// Subscribe to extension errors
extensionRunner.onError((error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
});
// Set up hook-registered shortcuts
this.setupHookShortcuts(hookRunner);
// Set up extension-registered shortcuts
this.setupExtensionShortcuts(extensionRunner);
// Show loaded hooks
const hookPaths = hookRunner.getHookPaths();
if (hookPaths.length > 0) {
const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0));
// Show loaded extensions
const extensionPaths = extensionRunner.getExtensionPaths();
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Emit session_start event
await hookRunner.emit({
await extensionRunner.emit({
type: "session_start",
});
}
/**
* Emit session event to all custom tools.
* Get a registered tool definition by name (for custom rendering).
*/
private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise<void> {
for (const { tool } of this.customTools.values()) {
if (tool.onSession) {
try {
await tool.onSession(event, {
sessionManager: this.session.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
hasPendingMessages: () => this.session.pendingMessageCount > 0,
abort: () => {
this.session.abort();
},
});
} catch (err) {
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
}
}
}
private getRegisteredToolDefinition(toolName: string) {
const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
const registeredTool = tools.find((t) => t.definition.name === toolName);
return registeredTool?.definition;
}
/**
* Show a tool error in the chat.
* Set up keyboard shortcuts registered by extensions.
*/
private showToolError(toolName: string, error: string): void {
const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
this.chatContainer.addChild(errorText);
this.ui.requestRender();
}
/**
* Set up keyboard shortcuts registered by hooks.
*/
private setupHookShortcuts(hookRunner: HookRunner): void {
const shortcuts = hookRunner.getShortcuts();
private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {
const shortcuts = extensionRunner.getShortcuts();
if (shortcuts.size === 0) return;
// Create a context for shortcut handlers
const createContext = (): HookContext => ({
ui: this.createHookUIContext(),
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
sessionManager: this.sessionManager,
@ -597,10 +560,10 @@ export class InteractiveMode {
hasPendingMessages: () => this.session.pendingMessageCount > 0,
});
// Set up the hook shortcut handler on the editor
this.editor.onHookShortcut = (data: string) => {
// Set up the extension shortcut handler on the editor
this.editor.onExtensionShortcut = (data: string) => {
for (const [shortcutStr, shortcut] of shortcuts) {
// Cast to KeyId - hook shortcuts use the same format
// Cast to KeyId - extension shortcuts use the same format
if (matchesKey(data, shortcutStr as KeyId)) {
// Run handler async, don't block input
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
@ -614,26 +577,26 @@ export class InteractiveMode {
}
/**
* Set hook status text in the footer.
* Set extension status text in the footer.
*/
private setHookStatus(key: string, text: string | undefined): void {
this.footer.setHookStatus(key, text);
private setExtensionStatus(key: string, text: string | undefined): void {
this.footer.setExtensionStatus(key, text);
this.ui.requestRender();
}
/**
* Set a hook widget (string array or custom component).
* Set an extension widget (string array or custom component).
*/
private setHookWidget(
private setExtensionWidget(
key: string,
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
): void {
// Dispose and remove existing widget
const existing = this.hookWidgets.get(key);
const existing = this.extensionWidgets.get(key);
if (existing?.dispose) existing.dispose();
if (content === undefined) {
this.hookWidgets.delete(key);
this.extensionWidgets.delete(key);
} else if (Array.isArray(content)) {
// Wrap string array in a Container with Text components
const container = new Container();
@ -643,11 +606,11 @@ export class InteractiveMode {
if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
}
this.hookWidgets.set(key, container);
this.extensionWidgets.set(key, container);
} else {
// Factory function - create component
const component = content(this.ui, theme);
this.hookWidgets.set(key, component);
this.extensionWidgets.set(key, component);
}
this.renderWidgets();
}
@ -656,18 +619,18 @@ export class InteractiveMode {
private static readonly MAX_WIDGET_LINES = 10;
/**
* Render all hook widgets to the widget container.
* Render all extension widgets to the widget container.
*/
private renderWidgets(): void {
if (!this.widgetContainer) return;
this.widgetContainer.clear();
if (this.hookWidgets.size === 0) {
if (this.extensionWidgets.size === 0) {
this.ui.requestRender();
return;
}
for (const [_key, component] of this.hookWidgets) {
for (const [_key, component] of this.extensionWidgets) {
this.widgetContainer.addChild(component);
}
@ -675,21 +638,21 @@ export class InteractiveMode {
}
/**
* Create the HookUIContext for hooks and tools.
* Create the ExtensionUIContext for extensions.
*/
private createHookUIContext(): HookUIContext {
private createExtensionUIContext(): ExtensionUIContext {
return {
select: (title, options) => this.showHookSelector(title, options),
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
setWidget: (key, content) => this.setHookWidget(key, content),
select: (title, options) => this.showExtensionSelector(title, options),
confirm: (title, message) => this.showExtensionConfirm(title, message),
input: (title, placeholder) => this.showExtensionInput(title, placeholder),
notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWidget: (key, content) => this.setExtensionWidget(key, content),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory) => this.showHookCustom(factory),
custom: (factory) => this.showExtensionCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
get theme() {
return theme;
},
@ -697,126 +660,126 @@ export class InteractiveMode {
}
/**
* Show a selector for hooks.
* Show a selector for extensions.
*/
private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
private showExtensionSelector(title: string, options: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookSelector = new HookSelectorComponent(
this.extensionSelector = new ExtensionSelectorComponent(
title,
options,
(option) => {
this.hideHookSelector();
this.hideExtensionSelector();
resolve(option);
},
() => {
this.hideHookSelector();
this.hideExtensionSelector();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookSelector);
this.ui.setFocus(this.hookSelector);
this.editorContainer.addChild(this.extensionSelector);
this.ui.setFocus(this.extensionSelector);
this.ui.requestRender();
});
}
/**
* Hide the hook selector.
* Hide the extension selector.
*/
private hideHookSelector(): void {
private hideExtensionSelector(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookSelector = undefined;
this.extensionSelector = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a confirmation dialog for hooks.
* Show a confirmation dialog for extensions.
*/
private async showHookConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
private async showExtensionConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"]);
return result === "Yes";
}
/**
* Show a text input for hooks.
* Show a text input for extensions.
*/
private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
private showExtensionInput(title: string, placeholder?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookInput = new HookInputComponent(
this.extensionInput = new ExtensionInputComponent(
title,
placeholder,
(value) => {
this.hideHookInput();
this.hideExtensionInput();
resolve(value);
},
() => {
this.hideHookInput();
this.hideExtensionInput();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookInput);
this.ui.setFocus(this.hookInput);
this.editorContainer.addChild(this.extensionInput);
this.ui.setFocus(this.extensionInput);
this.ui.requestRender();
});
}
/**
* Hide the hook input.
* Hide the extension input.
*/
private hideHookInput(): void {
private hideExtensionInput(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookInput = undefined;
this.extensionInput = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a multi-line editor for hooks (with Ctrl+G support).
* Show a multi-line editor for extensions (with Ctrl+G support).
*/
private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookEditor = new HookEditorComponent(
this.extensionEditor = new ExtensionEditorComponent(
this.ui,
title,
prefill,
(value) => {
this.hideHookEditor();
this.hideExtensionEditor();
resolve(value);
},
() => {
this.hideHookEditor();
this.hideExtensionEditor();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookEditor);
this.ui.setFocus(this.hookEditor);
this.editorContainer.addChild(this.extensionEditor);
this.ui.setFocus(this.extensionEditor);
this.ui.requestRender();
});
}
/**
* Hide the hook editor.
* Hide the extension editor.
*/
private hideHookEditor(): void {
private hideExtensionEditor(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookEditor = undefined;
this.extensionEditor = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a notification for hooks.
* Show a notification for extensions.
*/
private showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void {
if (type === "error") {
this.showError(message);
} else if (type === "warning") {
@ -829,7 +792,7 @@ export class InteractiveMode {
/**
* Show a custom component with keyboard focus.
*/
private async showHookCustom<T>(
private async showExtensionCustom<T>(
factory: (
tui: TUI,
theme: Theme,
@ -862,10 +825,10 @@ export class InteractiveMode {
}
/**
* Show a hook error in the UI.
* Show an extension error in the UI.
*/
private showHookError(hookPath: string, error: string, stack?: string): void {
const errorMsg = `Hook "${hookPath}" error: ${error}`;
private showExtensionError(extensionPath: string, error: string, stack?: string): void {
const errorMsg = `Extension "${extensionPath}" error: ${error}`;
const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
this.chatContainer.addChild(errorText);
if (stack) {
@ -882,10 +845,6 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Handle pi.send() from hooks.
* If streaming, queue the message. Otherwise, start a new agent loop.
*/
// =========================================================================
// Key Handlers
// =========================================================================
@ -984,7 +943,7 @@ export class InteractiveMode {
text = text.trim();
if (!text) return;
// Handle slash commands
// Handle commands
if (text === "/settings") {
this.showSettingsSelector();
this.editor.setText("");
@ -1106,7 +1065,7 @@ export class InteractiveMode {
}
// If streaming, use prompt() with steer behavior
// This handles hook commands (execute immediately), slash command expansion, and queueing
// This handles extension commands (execute immediately), prompt template expansion, and queueing
if (this.session.isStreaming) {
this.editor.addToHistory(text);
this.editor.setText("");
@ -1157,7 +1116,7 @@ export class InteractiveMode {
break;
case "message_start":
if (event.message.role === "hookMessage") {
if (event.message.role === "custom") {
this.addMessageToChat(event.message);
this.ui.requestRender();
} else if (event.message.role === "user") {
@ -1189,7 +1148,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(content.name)?.tool,
this.getRegisteredToolDefinition(content.name),
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
@ -1246,7 +1205,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
},
this.customTools.get(event.toolName)?.tool,
this.getRegisteredToolDefinition(event.toolName),
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
@ -1441,10 +1400,10 @@ export class InteractiveMode {
this.chatContainer.addChild(component);
break;
}
case "hookMessage": {
case "custom": {
if (message.display) {
const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
this.chatContainer.addChild(new HookMessageComponent(message, renderer));
const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType);
this.chatContainer.addChild(new CustomMessageComponent(message, renderer));
}
break;
}
@ -1516,7 +1475,7 @@ export class InteractiveMode {
content.name,
content.arguments,
{ showImages: this.settingsManager.getShowImages() },
this.customTools.get(content.name)?.tool,
this.getRegisteredToolDefinition(content.name),
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
@ -1601,20 +1560,17 @@ export class InteractiveMode {
/**
* Gracefully shutdown the agent.
* Emits shutdown event to hooks and tools, then exits.
* Emits shutdown event to extensions, then exits.
*/
private async shutdown(): Promise<void> {
// Emit shutdown event to hooks
const hookRunner = this.session.hookRunner;
if (hookRunner?.hasHandlers("session_shutdown")) {
await hookRunner.emit({
// Emit shutdown event to extensions
const extensionRunner = this.session.extensionRunner;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({
type: "session_shutdown",
});
}
// Emit shutdown event to custom tools
await this.session.emitCustomToolSessionEvent("shutdown");
this.stop();
process.exit(0);
}
@ -1638,7 +1594,7 @@ export class InteractiveMode {
if (!text) return;
// Alt+Enter queues a follow-up message (waits until agent finishes)
// This handles hook commands (execute immediately), slash command expansion, and queueing
// This handles extension commands (execute immediately), prompt template expansion, and queueing
if (this.session.isStreaming) {
this.editor.addToHistory(text);
this.editor.setText("");
@ -1979,7 +1935,7 @@ export class InteractiveMode {
async (entryId) => {
const result = await this.session.branch(entryId);
if (result.cancelled) {
// Hook cancelled the branch
// Extension cancelled the branch
done();
this.ui.requestRender();
return;
@ -2034,7 +1990,7 @@ export class InteractiveMode {
// Ask about summarization
done(); // Close selector first
const wantsSummary = await this.showHookConfirm(
const wantsSummary = await this.showExtensionConfirm(
"Summarize branch?",
"Create a summary of the branch you're leaving?",
);
@ -2137,7 +2093,7 @@ export class InteractiveMode {
this.streamingMessage = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits hook and tool session events)
// Switch session via AgentSession (emits extension session events)
await this.session.switchSession(sessionPath);
// Clear and re-render the chat
@ -2542,18 +2498,18 @@ export class InteractiveMode {
| \`!\` | Run bash command |
`;
// Add hook-registered shortcuts
const hookRunner = this.session.hookRunner;
if (hookRunner) {
const shortcuts = hookRunner.getShortcuts();
// Add extension-registered shortcuts
const extensionRunner = this.session.extensionRunner;
if (extensionRunner) {
const shortcuts = extensionRunner.getShortcuts();
if (shortcuts.size > 0) {
hotkeys += `
**Hooks**
**Extensions**
| Key | Action |
|-----|--------|
`;
for (const [key, shortcut] of shortcuts) {
const description = shortcut.description ?? shortcut.hookPath;
const description = shortcut.description ?? shortcut.extensionPath;
hotkeys += `| \`${key}\` | ${description} |\n`;
}
}
@ -2576,7 +2532,7 @@ export class InteractiveMode {
}
this.statusContainer.clear();
// New session via session (emits hook and tool session events)
// New session via session (emits extension session events)
await this.session.newSession();
// Clear UI state

View file

@ -26,15 +26,15 @@ export async function runPrintMode(
initialMessage?: string,
initialImages?: ImageContent[],
): Promise<void> {
// Hook runner already has no-op UI context by default (set in main.ts)
// Set up hooks for print mode (no UI)
const hookRunner = session.hookRunner;
if (hookRunner) {
hookRunner.initialize({
// Extension runner already has no-op UI context by default (set in loader)
// Set up extensions for print mode (no UI)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize({
getModel: () => session.model,
sendMessageHandler: (message, options) => {
session.sendHookMessage(message, options).catch((e) => {
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
session.sendCustomMessage(message, options).catch((e) => {
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntryHandler: (customType, data) => {
@ -44,41 +44,15 @@ export async function runPrintMode(
getAllToolsHandler: () => session.getAllToolNames(),
setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
});
hookRunner.onError((err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`);
extensionRunner.onError((err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
});
// Emit session_start event
await hookRunner.emit({
await extensionRunner.emit({
type: "session_start",
});
}
// Emit session start event to custom tools (no UI in print mode)
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession(
{
reason: "start",
previousSessionFile: undefined,
},
{
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
isIdle: () => !session.isStreaming,
hasPendingMessages: () => session.pendingMessageCount > 0,
abort: () => {
session.abort();
},
},
);
} catch (_err) {
// Silently ignore tool errors
}
}
}
// Always subscribe to enable session persistence via _handleAgentEvent
session.subscribe((event) => {
// In JSON mode, output all events

View file

@ -196,7 +196,7 @@ export class RpcClient {
/**
* Start a new session, optionally with parent tracking.
* @param parentSession - Optional parent session path for lineage tracking
* @returns Object with `cancelled: true` if a hook cancelled the new session
* @returns Object with `cancelled: true` if an extension cancelled the new session
*/
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "new_session", parentSession });
@ -330,7 +330,7 @@ export class RpcClient {
/**
* Switch to a different session file.
* @returns Object with `cancelled: true` if a hook cancelled the switch
* @returns Object with `cancelled: true` if an extension cancelled the switch
*/
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "switch_session", sessionPath });
@ -339,7 +339,7 @@ export class RpcClient {
/**
* Branch from a specific message.
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
* @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
*/
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryId });

View file

@ -8,25 +8,37 @@
* - Commands: JSON objects with `type` field, optional `id` for correlation
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
* - Events: AgentSessionEvent objects streamed as they occur
* - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
*/
import * as crypto from "node:crypto";
import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js";
import type { HookUIContext } from "../../core/hooks/index.js";
import type { ExtensionUIContext } from "../../core/extensions/index.js";
import { theme } from "../interactive/theme/theme.js";
import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
import type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
RpcSessionState,
} from "./rpc-types.js";
// Re-export types for consumers
export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js";
export type {
RpcCommand,
RpcExtensionUIRequest,
RpcExtensionUIResponse,
RpcResponse,
RpcSessionState,
} from "./rpc-types.js";
/**
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(session: AgentSession): Promise<never> {
const output = (obj: RpcResponse | RpcHookUIRequest | object) => {
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
console.log(JSON.stringify(obj));
};
@ -45,18 +57,21 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return { id, type: "response", command, success: false, error: message };
};
// Pending hook UI requests waiting for response
const pendingHookRequests = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
// Pending extension UI requests waiting for response
const pendingExtensionRequests = new Map<
string,
{ resolve: (value: any) => void; reject: (error: Error) => void }
>();
/**
* Create a hook UI context that uses the RPC protocol.
* Create an extension UI context that uses the RPC protocol.
*/
const createHookUIContext = (): HookUIContext => ({
const createExtensionUIContext = (): ExtensionUIContext => ({
async select(title: string, options: string[]): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
@ -67,15 +82,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
reject,
});
output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest);
output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest);
});
},
async confirm(title: string, message: string): Promise<boolean> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(false);
} else if ("confirmed" in response) {
@ -86,15 +101,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
reject,
});
output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest);
output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest);
});
},
async input(title: string, placeholder?: string): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
@ -105,42 +120,42 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
reject,
});
output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest);
output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest);
});
},
notify(message: string, type?: "info" | "warning" | "error"): void {
// Fire and forget - no response needed
output({
type: "hook_ui_request",
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "notify",
message,
notifyType: type,
} as RpcHookUIRequest);
} as RpcExtensionUIRequest);
},
setStatus(key: string, text: string | undefined): void {
// Fire and forget - no response needed
output({
type: "hook_ui_request",
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setStatus",
statusKey: key,
statusText: text,
} as RpcHookUIRequest);
} as RpcExtensionUIRequest);
},
setWidget(key: string, content: unknown): void {
// Only support string arrays in RPC mode - factory functions are ignored
if (content === undefined || Array.isArray(content)) {
output({
type: "hook_ui_request",
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setWidget",
widgetKey: key,
widgetLines: content as string[] | undefined,
} as RpcHookUIRequest);
} as RpcExtensionUIRequest);
}
// Component factories are not supported in RPC mode - would need TUI access
},
@ -148,11 +163,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
setTitle(title: string): void {
// Fire and forget - host can implement terminal title control
output({
type: "hook_ui_request",
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "setTitle",
title,
} as RpcHookUIRequest);
} as RpcExtensionUIRequest);
},
async custom() {
@ -163,11 +178,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
setEditorText(text: string): void {
// Fire and forget - host can implement editor control
output({
type: "hook_ui_request",
type: "extension_ui_request",
id: crypto.randomUUID(),
method: "set_editor_text",
text,
} as RpcHookUIRequest);
} as RpcExtensionUIRequest);
},
getEditorText(): string {
@ -179,8 +194,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
async editor(title: string, prefill?: string): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
pendingExtensionRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(undefined);
} else if ("value" in response) {
@ -191,7 +206,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
reject,
});
output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest);
output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest);
});
},
@ -200,14 +215,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
});
// Set up hooks with RPC-based UI context
const hookRunner = session.hookRunner;
if (hookRunner) {
hookRunner.initialize({
// Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize({
getModel: () => session.agent.state.model,
sendMessageHandler: (message, options) => {
session.sendHookMessage(message, options).catch((e) => {
output(error(undefined, "hook_send", e.message));
session.sendCustomMessage(message, options).catch((e) => {
output(error(undefined, "extension_send", e.message));
});
},
appendEntryHandler: (customType, data) => {
@ -216,45 +231,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
getActiveToolsHandler: () => session.getActiveToolNames(),
getAllToolsHandler: () => session.getAllToolNames(),
setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
uiContext: createHookUIContext(),
uiContext: createExtensionUIContext(),
hasUI: false,
});
hookRunner.onError((err) => {
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
extensionRunner.onError((err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
});
// Emit session_start event
await hookRunner.emit({
await extensionRunner.emit({
type: "session_start",
});
}
// Emit session start event to custom tools
// Note: Tools get no-op UI context in RPC mode (host handles UI via protocol)
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession(
{
previousSessionFile: undefined,
reason: "start",
},
{
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
isIdle: () => !session.isStreaming,
hasPendingMessages: () => session.pendingMessageCount > 0,
abort: () => {
session.abort();
},
},
);
} catch (_err) {
// Silently ignore tool errors
}
}
}
// Output all agent events as JSON
session.subscribe((event) => {
output(event);
@ -271,7 +259,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
case "prompt": {
// Don't await - events will stream
// Hook commands are executed immediately, file slash commands are expanded
// Extension commands are executed immediately, file prompt templates are expanded
// If streaming and streamingBehavior specified, queues via steer/followUp
session
.prompt(command.message, {
@ -484,12 +472,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
try {
const parsed = JSON.parse(line);
// Handle hook UI responses
if (parsed.type === "hook_ui_response") {
const response = parsed as RpcHookUIResponse;
const pending = pendingHookRequests.get(response.id);
// Handle extension UI responses
if (parsed.type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pendingHookRequests.delete(response.id);
pendingExtensionRequests.delete(response.id);
pending.resolve(response);
}
return;

View file

@ -172,42 +172,48 @@ export type RpcResponse =
| { id?: string; type: "response"; command: string; success: false; error: string };
// ============================================================================
// Hook UI Events (stdout)
// Extension UI Events (stdout)
// ============================================================================
/** Emitted when a hook needs user input */
export type RpcHookUIRequest =
| { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] }
| { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string }
| { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
| { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
/** Emitted when an extension needs user input */
export type RpcExtensionUIRequest =
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] }
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string }
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string }
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
| {
type: "hook_ui_request";
type: "extension_ui_request";
id: string;
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
}
| { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined }
| {
type: "hook_ui_request";
type: "extension_ui_request";
id: string;
method: "setStatus";
statusKey: string;
statusText: string | undefined;
}
| {
type: "extension_ui_request";
id: string;
method: "setWidget";
widgetKey: string;
widgetLines: string[] | undefined;
}
| { type: "hook_ui_request"; id: string; method: "setTitle"; title: string }
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
// ============================================================================
// Hook UI Commands (stdin)
// Extension UI Commands (stdin)
// ============================================================================
/** Response to a hook UI request */
export type RpcHookUIResponse =
| { type: "hook_ui_response"; id: string; value: string }
| { type: "hook_ui_response"; id: string; confirmed: boolean }
| { type: "hook_ui_response"; id: string; cancelled: true };
/** Response to an extension UI request */
export type RpcExtensionUIResponse =
| { type: "extension_ui_response"; id: string; value: string }
| { type: "extension_ui_response"; id: string; confirmed: boolean }
| { type: "extension_ui_response"; id: string; cancelled: true };
// ============================================================================
// Helper type for extracting command types