mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 00:03:00 +00:00
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:
parent
9794868b38
commit
c6fc084534
112 changed files with 2842 additions and 6747 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue