diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index b99fdb8c..26d9b01c 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -133,7 +133,7 @@ Behavior: **Renamed:** - `renderCustomMessage()` → `registerCustomMessageRenderer()` -**New: `sendMessage()`** +**New: `sendMessage()` ✅** Replaces `send()`. Always creates CustomMessageEntry, never user messages. @@ -143,34 +143,57 @@ type HookMessage = Pick, 'customType' | 'cont sendMessage(message: HookMessage, triggerTurn?: boolean): void; ``` -Behavior: -- If streaming → queue, append after turn ends (never triggers turn) -- If idle AND `triggerTurn: true` → append and trigger turn -- If idle AND `triggerTurn: false` (default) → just append, no turn -- TUI updates if `display: true` +Implementation: +- Uses agent's queue mechanism with `_hookData` marker on AppMessage +- `message_end` handler routes based on marker presence +- `AgentSession.sendHookMessage()` handles three cases: + - Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end` + - Not streaming + triggerTurn: direct append + `agent.continue()` + - Not streaming + no trigger: direct append only +- TUI updates via event (streaming) or explicit rebuild (non-streaming) -For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly. +**New: `appendEntry()` ✅** -**New: `registerCommand()`** +For hook state persistence (NOT in LLM context): + +```typescript +appendEntry(customType: string, data?: unknown): void; +``` + +Calls `sessionManager.appendCustomEntry()` directly. + +**New: `registerCommand()` (types ✅, wiring TODO)** ```typescript interface CommandContext { args: string; // Everything after /commandname - session: LimitedAgentSession; // No prompt(), use sendMessage() ui: HookUIContext; + hasUI: boolean; + cwd: string; + sessionManager: SessionManager; + modelRegistry: ModelRegistry; + sendMessage: HookAPI['sendMessage']; exec(command: string, args: string[], options?: ExecOptions): Promise; } registerCommand(name: string, options: { description?: string; - handler: (ctx: CommandContext) => Promise; + handler: (ctx: CommandContext) => Promise; }): void; ``` Handler return: -- `void` - command completed +- `undefined` - command completed - `string` - text to send as prompt (like file-based slash commands) +Wiring (all in AgentSession.prompt()): +- [x] Add hook commands to autocomplete in interactive-mode +- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution +- [x] Build CommandContext with ui (from hookRunner), exec, sessionManager, etc. +- [x] If handler returns string, use as prompt text +- [x] If handler returns undefined, return early (no LLM call) +- [x] Works for all modes (interactive, RPC, print) via shared AgentSession + **New: `ui.custom()`** For arbitrary hook UI with keyboard focus: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e7a352d4..cf0942f8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,7 +27,16 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; +import { + type CommandContext, + type ExecOptions, + execCommand, + type HookMessage, + type HookRunner, + type SessionEventResult, + type TurnEndEvent, + type TurnStartEvent, +} from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; @@ -441,6 +450,7 @@ export class AgentSession { /** * Send a prompt to the agent. * - Validates model and API key before sending + * - Handles hook commands (registered via pi.registerCommand) * - Expands file-based slash commands by default * @throws Error if no model selected or no API key available */ @@ -450,6 +460,20 @@ export class AgentSession { const expandCommands = options?.expandSlashCommands ?? true; + // Handle hook commands first (if enabled and text is a slash command) + if (expandCommands && text.startsWith("/")) { + const result = await this._tryExecuteHookCommand(text); + if (result.handled) { + if (result.prompt) { + // Hook returned text to use as prompt + text = result.prompt; + } else { + // Hook command executed, no prompt to send + return; + } + } + } + // Validate model if (!this.model) { throw new Error( @@ -474,13 +498,65 @@ export class AgentSession { await this._checkCompaction(lastAssistant, false); } - // Expand slash commands if requested + // Expand file-based slash commands if requested const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; await this.agent.prompt(expandedText, options?.attachments); await this.waitForRetry(); } + /** + * Try to execute a hook command. Returns whether it was handled and optional prompt text. + */ + private async _tryExecuteHookCommand(text: string): Promise<{ handled: boolean; prompt?: string }> { + if (!this._hookRunner) return { handled: 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); + if (!command) return { handled: false }; + + // Get UI context from hook runner (set by mode) + const uiContext = this._hookRunner.getUIContext(); + if (!uiContext) return { handled: false }; + + // Build command context + const cwd = process.cwd(); + const ctx: CommandContext = { + args, + ui: uiContext, + hasUI: this._hookRunner.getHasUI(), + cwd, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, + sendMessage: (message, triggerTurn) => { + this.sendHookMessage(message, triggerTurn).catch(() => { + // Error handling is done in sendHookMessage + }); + }, + exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options), + }; + + try { + const result = await command.handler(ctx); + if (typeof result === "string") { + return { handled: true, prompt: result }; + } + return { handled: true }; + } catch (err) { + // Emit error via hook runner + this._hookRunner.emitError({ + hookPath: `command:${commandName}`, + event: "command", + error: err instanceof Error ? err.message : String(err), + }); + return { handled: true }; + } + } + /** * Queue a message to be sent after the current response completes. * Use when agent is currently streaming. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 1e0a5488..43901993 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -6,16 +6,18 @@ export { loadHooks, type SendMessageHandler, } from "./loader.js"; -export { type HookErrorListener, HookRunner } from "./runner.js"; +export { execCommand, type HookErrorListener, HookRunner } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + CommandContext, CustomMessageRenderer, CustomMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, + ExecOptions, ExecResult, FindToolResultEvent, GrepToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 78277f85..e2efb149 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -34,7 +34,12 @@ export type HookErrorListener = (error: HookError) => void; * Execute a command and return stdout/stderr/code. * Supports cancellation via AbortSignal and timeout. */ -async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise { +export async function execCommand( + command: string, + args: string[], + cwd: string, + options?: ExecOptions, +): Promise { return new Promise((resolve) => { const proc = spawn(command, args, { cwd, shell: false }); @@ -150,6 +155,20 @@ export class HookRunner { this.hasUI = hasUI; } + /** + * 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. */ @@ -196,7 +215,10 @@ export class HookRunner { /** * Emit an error to all listeners. */ - private emitError(error: HookError): void { + /** + * Emit an error to all error listeners. + */ + emitError(error: HookError): void { for (const listener of this.errorListeners) { listener(error); } @@ -261,7 +283,8 @@ export class HookRunner { */ private createContext(): HookEventContext { return { - exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options), + exec: (command: string, args: string[], options?: ExecOptions) => + execCommand(command, args, this.cwd, options), ui: this.uiContext, hasUI: this.hasUI, cwd: this.cwd, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1f2c8d3a..fb320316 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -179,9 +179,15 @@ export class InteractiveMode { 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)", + })); + // Setup autocomplete const autocompleteProvider = new CombinedAutocompleteProvider( - [...slashCommands, ...fileSlashCommands], + [...slashCommands, ...fileSlashCommands, ...hookCommands], process.cwd(), fdPath, ); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 4cf17630..3840fb61 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -182,10 +182,10 @@ export async function runRpcMode(session: AgentSession): Promise { case "prompt": { // Don't await - events will stream + // Hook commands and file slash commands are handled in session.prompt() session .prompt(command.message, { attachments: command.attachments, - expandSlashCommands: false, }) .catch((e) => output(error(id, "prompt", e.message))); return success(id, "prompt");