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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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