diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c7fb9c8..b68ff18a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,9 +28,14 @@ - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) - **Hook API**: - - New `pi.renderCustomMessage(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages + - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) + - New `pi.registerCommand(name, options)` to register custom slash commands + - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box + - New types: `HookMessage`, `RegisteredCommand`, `CommandContext` + - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) - **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens @@ -46,6 +51,7 @@ - **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `CustomMessageEntry` for hook-injected context messages, `LabelEntry` for user-defined bookmarks - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. - **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. +- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 204cb598..b99fdb8c 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -122,10 +122,67 @@ Behavior: - [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager - [x] `buildSessionContext()` includes custom_message entries as user messages - [x] Exported from main index -- [ ] TUI rendering: +- [x] TUI rendering: - `display: false` - hidden entirely - - `display: true` - baseline renderer (content with different bg/fg color) - - Custom renderer defined by the hook that contributes it (future) + - `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) + - [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers + - [x] Renderer returns inner Component, TUI wraps in styled Box + +### Hook API Changes + +**Renamed:** +- `renderCustomMessage()` → `registerCustomMessageRenderer()` + +**New: `sendMessage()`** + +Replaces `send()`. Always creates CustomMessageEntry, never user messages. + +```typescript +type HookMessage = Pick, 'customType' | 'content' | 'display' | 'details'>; + +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` + +For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly. + +**New: `registerCommand()`** + +```typescript +interface CommandContext { + args: string; // Everything after /commandname + session: LimitedAgentSession; // No prompt(), use sendMessage() + ui: HookUIContext; + exec(command: string, args: string[], options?: ExecOptions): Promise; +} + +registerCommand(name: string, options: { + description?: string; + handler: (ctx: CommandContext) => Promise; +}): void; +``` + +Handler return: +- `void` - command completed +- `string` - text to send as prompt (like file-based slash commands) + +**New: `ui.custom()`** + +For arbitrary hook UI with keyboard focus: + +```typescript +interface HookUIContext { + // ... existing: select, confirm, input, notify + + /** Show custom component with keyboard focus. Call done() when finished. */ + custom(component: Component, done: () => void): void; +} +``` See also: `CustomEntry` for storing hook state that does NOT participate in context. diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index df98d2a2..74f6314b 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -21,7 +21,14 @@ export default function (pi: HookAPI) { try { const content = fs.readFileSync(triggerFile, "utf-8").trim(); if (content) { - pi.send(`External trigger: ${content}`); + pi.sendMessage( + { + customType: "file-trigger", + content: `External trigger: ${content}`, + display: true, + }, + true, // triggerTurn - get LLM to respond + ); fs.writeFileSync(triggerFile, ""); // Clear after reading } } catch { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0281e5d5..e7a352d4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; @@ -27,7 +27,7 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; +import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, 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"; @@ -101,6 +101,13 @@ export interface SessionStats { cost: number; } +/** Internal marker for hook messages queued through the agent loop */ +interface HookMessageData { + customType: string; + display: boolean; + details?: unknown; +} + // ============================================================================ // Constants // ============================================================================ @@ -211,7 +218,21 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - this.sessionManager.appendMessage(event.message); + // Check if this is a hook message (has _hookData marker) + type HookAppMessage = AppMessage & { _hookData?: HookMessageData; content: (TextContent | ImageContent)[] }; + const hookMessage = event.message as HookAppMessage; + if (hookMessage._hookData) { + // Persist as CustomMessageEntry + this.sessionManager.appendCustomMessageEntry( + hookMessage._hookData.customType, + hookMessage.content, + hookMessage._hookData.display, + hookMessage._hookData.details, + ); + } else { + // Regular message - persist as SessionMessageEntry + this.sessionManager.appendMessage(event.message); + } // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { @@ -473,6 +494,60 @@ export class AgentSession { }); } + /** + * Send a hook 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 triggerTurn If true and not streaming, triggers a new LLM turn + */ + async sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise { + // Normalize content to array format for the AppMessage + const content: (TextContent | ImageContent)[] = + typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; + + // Create AppMessage with _hookData marker for routing in message_end handler + const appMessage: AppMessage & { _hookData: HookMessageData } = { + role: "user", + content, + timestamp: Date.now(), + _hookData: { + customType: message.customType, + display: message.display, + details: message.details, + }, + }; + + if (this.isStreaming) { + // Queue for processing by agent loop + await this.agent.queueMessage(appMessage); + } else if (triggerTurn) { + // Append to agent state and session, then trigger a turn + this.agent.appendMessage(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + // Start a new turn - agent.continue() works because last message is user role + await this.agent.continue(); + } else { + // Just append to agent state and session, no turn + this.agent.appendMessage(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + } + } + /** * Clear queued messages and return them. * Useful for restoring to editor when user aborts. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 7524b605..1e0a5488 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -1,4 +1,11 @@ -export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js"; +export { + type AppendEntryHandler, + discoverAndLoadHooks, + type LoadedHook, + type LoadHooksResult, + loadHooks, + type SendMessageHandler, +} from "./loader.js"; export { type HookErrorListener, HookRunner } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { @@ -17,9 +24,11 @@ export type { HookEvent, HookEventContext, HookFactory, + HookMessage, HookUIContext, LsToolResultEvent, ReadToolResultEvent, + RegisteredCommand, SessionEvent, SessionEventResult, ToolCallEvent, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 5f4fef75..736ed673 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -7,10 +7,9 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Attachment } from "@mariozechner/pi-agent-core"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; -import type { CustomMessageRenderer, HookAPI, HookFactory } from "./types.js"; +import type { CustomMessageRenderer, HookAPI, HookFactory, HookMessage, RegisteredCommand } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -47,9 +46,14 @@ function getAliases(): Record { type HandlerFn = (...args: unknown[]) => Promise; /** - * Send handler type for pi.send(). + * Send message handler type for pi.sendMessage(). */ -export type SendHandler = (text: string, attachments?: Attachment[]) => void; +export type SendMessageHandler = (message: HookMessage, triggerTurn?: boolean) => void; + +/** + * Append entry handler type for pi.appendEntry(). + */ +export type AppendEntryHandler = (customType: string, data?: T) => void; /** * Registered handlers for a loaded hook. @@ -63,8 +67,12 @@ export interface LoadedHook { handlers: Map; /** Map of customType to custom message renderer */ customMessageRenderers: Map; - /** Set the send handler for this hook's pi.send() */ - setSendHandler: (handler: SendHandler) => void; + /** Map of command name to registered command */ + commands: Map; + /** 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; } /** @@ -112,18 +120,24 @@ function resolveHookPath(hookPath: string, cwd: string): string { } /** - * Create a HookAPI instance that collects handlers and renderers. - * Returns the API, renderers map, and a function to set the send handler later. + * Create a HookAPI instance that collects handlers, renderers, and commands. + * Returns the API, maps, and a function to set the send message handler later. */ function createHookAPI(handlers: Map): { api: HookAPI; customMessageRenderers: Map; - setSendHandler: (handler: SendHandler) => void; + commands: Map; + setSendMessageHandler: (handler: SendMessageHandler) => void; + setAppendEntryHandler: (handler: AppendEntryHandler) => void; } { - let sendHandler: SendHandler = () => { + let sendMessageHandler: SendMessageHandler = () => { + // Default no-op until mode sets the handler + }; + let appendEntryHandler: AppendEntryHandler = () => { // Default no-op until mode sets the handler }; const customMessageRenderers = new Map(); + const commands = new Map(); const api: HookAPI = { on(event: string, handler: HandlerFn): void { @@ -131,19 +145,29 @@ function createHookAPI(handlers: Map): { list.push(handler); handlers.set(event, list); }, - send(text: string, attachments?: Attachment[]): void { - sendHandler(text, attachments); + sendMessage(message: HookMessage, triggerTurn?: boolean): void { + sendMessageHandler(message, triggerTurn); }, - renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void { + appendEntry(customType: string, data?: T): void { + appendEntryHandler(customType, data); + }, + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { customMessageRenderers.set(customType, renderer); }, + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { + commands.set(name, { name, ...options }); + }, } as HookAPI; return { api, customMessageRenderers, - setSendHandler: (handler: SendHandler) => { - sendHandler = handler; + commands, + setSendMessageHandler: (handler: SendMessageHandler) => { + sendMessageHandler = handler; + }, + setAppendEntryHandler: (handler: AppendEntryHandler) => { + appendEntryHandler = handler; }, }; } @@ -172,13 +196,22 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, customMessageRenderers, setSendHandler } = createHookAPI(handlers); + const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = + createHookAPI(handlers); // Call factory to register handlers factory(api); return { - hook: { path: hookPath, resolvedPath, handlers, customMessageRenderers, setSendHandler }, + hook: { + path: hookPath, + resolvedPath, + handlers, + customMessageRenderers, + commands, + setSendMessageHandler, + setAppendEntryHandler, + }, error: null, }; } catch (err) { diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 2d4c9d2a..78277f85 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,7 +3,7 @@ */ import { spawn } from "node:child_process"; -import type { LoadedHook, SendHandler } from "./loader.js"; +import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { CustomMessageRenderer, ExecOptions, @@ -12,6 +12,7 @@ import type { HookEvent, HookEventContext, HookUIContext, + RegisteredCommand, SessionEvent, SessionEventResult, ToolCallEvent, @@ -164,12 +165,22 @@ export class HookRunner { } /** - * Set the send handler for all hooks' pi.send(). + * Set the send message handler for all hooks' pi.sendMessage(). * Call this when the mode initializes. */ - setSendHandler(handler: SendHandler): void { + setSendMessageHandler(handler: SendMessageHandler): void { for (const hook of this.hooks) { - hook.setSendHandler(handler); + hook.setSendMessageHandler(handler); + } + } + + /** + * Set the append entry handler for all hooks' pi.appendEntry(). + * Call this when the mode initializes. + */ + setAppendEntryHandler(handler: AppendEntryHandler): void { + for (const hook of this.hooks) { + hook.setAppendEntryHandler(handler); } } @@ -218,6 +229,33 @@ export class HookRunner { 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. */ diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6b46a903..28902cb7 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -5,7 +5,7 @@ * and interact with the user via UI primitives. */ -import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; +import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; @@ -388,9 +388,50 @@ export type CustomMessageRenderer = ( theme: Theme, ) => Component | null; +/** + * Message type for hooks to send. Creates CustomMessageEntry in the session. + */ +export type HookMessage = Pick, "customType" | "content" | "display" | "details">; + +/** + * Context passed to command handlers. + */ +export interface CommandContext { + /** Arguments after the command name */ + args: string; + /** UI methods for user interaction */ + ui: HookUIContext; + /** Execute a command and return stdout/stderr/code */ + exec(command: string, args: string[], options?: ExecOptions): Promise; + /** Whether UI is available (false in print mode) */ + hasUI: boolean; + /** Current working directory */ + cwd: string; + /** Session manager for reading/writing session entries */ + sessionManager: SessionManager; + /** Model registry for API keys */ + modelRegistry: ModelRegistry; + /** + * Send a custom message to the session. + * If streaming, queued and appended after turn ends. + * If idle and triggerTurn=true, appends and triggers a new turn. + * If idle and triggerTurn=false (default), just appends. + */ + sendMessage(message: HookMessage, triggerTurn?: boolean): void; +} + +/** + * Command registration options. + */ +export interface RegisteredCommand { + name: string; + description?: string; + handler: (ctx: CommandContext) => Promise; +} + /** * HookAPI passed to hook factory functions. - * Hooks use pi.on() to subscribe to events and pi.send() to inject messages. + * Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages. */ export interface HookAPI { // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything @@ -403,18 +444,62 @@ export interface HookAPI { on(event: "tool_result", handler: HookHandler): void; /** - * Send a message to the agent. - * If the agent is streaming, the message is queued. - * If the agent is idle, a new agent loop is started. + * 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 triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. + * If agent is streaming, message is queued and triggerTurn is ignored. */ - send(text: string, attachments?: Attachment[]): void; + sendMessage(message: HookMessage, triggerTurn?: boolean): 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(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 null to use the default renderer. */ - renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void; + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void; + + /** + * Register a custom slash command. + * Handler receives CommandContext and can return a string to send as prompt. + */ + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c78a6bd9..8397d2e9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -341,7 +341,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa return definitions.map((def) => { const handlers = new Map Promise>>(); const customMessageRenderers = new Map(); - let sendHandler: (text: string, attachments?: any[]) => void = () => {}; + const commands = new Map(); + let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; + let appendEntryHandler: (customType: string, data?: any) => void = () => {}; const api = { on: (event: string, handler: (...args: unknown[]) => Promise) => { @@ -349,12 +351,18 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa list.push(handler); handlers.set(event, list); }, - send: (text: string, attachments?: any[]) => { - sendHandler(text, attachments); + sendMessage: (message: any, triggerTurn?: boolean) => { + sendMessageHandler(message, triggerTurn); }, - renderCustomMessage: (customType: string, renderer: any) => { + appendEntry: (customType: string, data?: any) => { + appendEntryHandler(customType, data); + }, + registerCustomMessageRenderer: (customType: string, renderer: any) => { customMessageRenderers.set(customType, renderer); }, + registerCommand: (name: string, options: any) => { + commands.set(name, { name, ...options }); + }, }; def.factory(api as any); @@ -364,8 +372,12 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa resolvedPath: def.path ?? "", handlers, customMessageRenderers, - setSendHandler: (handler: (text: string, attachments?: any[]) => void) => { - sendHandler = handler; + commands, + setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { + sendMessageHandler = handler; + }, + setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { + appendEntryHandler = handler; }, }; }); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ebd013f2..1f2c8d3a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core"; +import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -370,9 +370,24 @@ export class InteractiveMode { this.showHookError(error.hookPath, error.error); }); - // Set up send handler for pi.send() - hookRunner.setSendHandler((text, attachments) => { - this.handleHookSend(text, attachments); + // Set up handlers for pi.sendMessage() and pi.appendEntry() + hookRunner.setSendMessageHandler((message, triggerTurn) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendHookMessage(message, triggerTurn) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err) => { + this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); }); // Show loaded hooks @@ -534,19 +549,6 @@ export class InteractiveMode { * Handle pi.send() from hooks. * If streaming, queue the message. Otherwise, start a new agent loop. */ - private handleHookSend(text: string, attachments?: Attachment[]): void { - if (this.session.isStreaming) { - // Queue the message for later (note: attachments are lost when queuing) - this.session.queueMessage(text); - this.updatePendingMessagesDisplay(); - } else { - // Start a new agent loop immediately - this.session.prompt(text, { attachments }).catch((err) => { - this.showError(err instanceof Error ? err.message : String(err)); - }); - } - } - // ========================================================================= // Key Handlers // ========================================================================= diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index af1e48d8..9375dd5c 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -39,9 +39,14 @@ export async function runPrintMode( hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); - // No-op send handler for print mode (single-shot, no async messages) - hookRunner.setSendHandler(() => { - console.error("Warning: pi.send() is not supported in print mode"); + // Set up handlers - sendHookMessage handles queuing/direct append as needed + hookRunner.setSendMessageHandler((message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); }); // Emit session event await hookRunner.emit({ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index eb0ad807..4cf17630 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -131,16 +131,14 @@ export async function runRpcMode(session: AgentSession): Promise { hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); - // Set up send handler for pi.send() - hookRunner.setSendHandler((text, attachments) => { - // In RPC mode, just queue or prompt based on streaming state - if (session.isStreaming) { - session.queueMessage(text); - } else { - session.prompt(text, { attachments }).catch((e) => { - output(error(undefined, "hook_send", e.message)); - }); - } + // Set up handlers for pi.sendMessage() and pi.appendEntry() + hookRunner.setSendMessageHandler((message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + output(error(undefined, "hook_send", e.message)); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); }); // Emit session event await hookRunner.emit({ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index b24618e8..6e9a071d 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -64,7 +64,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { resolvedPath: "/test/test-hook.ts", handlers, customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; } @@ -240,7 +242,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; createSession([throwingHook]); @@ -284,7 +288,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; const hook2: LoadedHook = { @@ -307,7 +313,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; createSession([hook1, hook2]);