mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
Hook API: replace send() with sendMessage(), add appendEntry() and registerCommand()
Breaking changes to Hook API: - pi.send(text, attachments?) replaced with pi.sendMessage(message, triggerTurn?) - Creates CustomMessageEntry instead of user messages - Properly handles queuing during streaming via agent loop - Supports optional turn triggering when idle - New pi.appendEntry(customType, data?) for hook state persistence - New pi.registerCommand(name, options) for custom slash commands - Handler types renamed: SendHandler -> SendMessageHandler, new AppendEntryHandler Implementation: - AgentSession.sendHookMessage() handles all three cases: - Streaming: queues message with _hookData marker, agent loop processes it - Not streaming + triggerTurn: appends to state/session, calls agent.continue() - Not streaming + no trigger: appends to state/session only - message_end handler routes based on _hookData presence to correct persistence - HookRunner gains getRegisteredCommands() and getCommand() methods New types: HookMessage<T>, RegisteredCommand, CommandContext
This commit is contained in:
parent
d43a5e47a1
commit
ba185b0571
13 changed files with 412 additions and 77 deletions
|
|
@ -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<T>`, `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<T>` for hook state persistence, `CustomMessageEntry<T>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T = unknown> = Pick<CustomMessageEntry<T>, '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<ExecResult>;
|
||||
}
|
||||
|
||||
registerCommand(name: string, options: {
|
||||
description?: string;
|
||||
handler: (ctx: CommandContext) => Promise<string | void>;
|
||||
}): 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<T>` for storing hook state that does NOT participate in context.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): Promise<void> {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
|||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Send handler type for pi.send().
|
||||
* Send message handler type for pi.sendMessage().
|
||||
*/
|
||||
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
|
||||
export type SendMessageHandler = <T = unknown>(message: HookMessage<T>, triggerTurn?: boolean) => void;
|
||||
|
||||
/**
|
||||
* Append entry handler type for pi.appendEntry().
|
||||
*/
|
||||
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||
|
||||
/**
|
||||
* Registered handlers for a loaded hook.
|
||||
|
|
@ -63,8 +67,12 @@ export interface LoadedHook {
|
|||
handlers: Map<string, HandlerFn[]>;
|
||||
/** Map of customType to custom message renderer */
|
||||
customMessageRenderers: Map<string, CustomMessageRenderer>;
|
||||
/** Set the send handler for this hook's pi.send() */
|
||||
setSendHandler: (handler: SendHandler) => void;
|
||||
/** Map of command name to registered command */
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
/** 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<string, HandlerFn[]>): {
|
||||
api: HookAPI;
|
||||
customMessageRenderers: Map<string, CustomMessageRenderer>;
|
||||
setSendHandler: (handler: SendHandler) => void;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
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<string, CustomMessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
|
||||
const api: HookAPI = {
|
||||
on(event: string, handler: HandlerFn): void {
|
||||
|
|
@ -131,19 +145,29 @@ function createHookAPI(handlers: Map<string, HandlerFn[]>): {
|
|||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
send(text: string, attachments?: Attachment[]): void {
|
||||
sendHandler(text, attachments);
|
||||
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
|
||||
sendMessageHandler(message, triggerTurn);
|
||||
},
|
||||
renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void {
|
||||
appendEntry<T = unknown>(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<string, HandlerFn[]>();
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<T = unknown> = (
|
|||
theme: Theme,
|
||||
) => Component | null;
|
||||
|
||||
/**
|
||||
* Message type for hooks to send. Creates CustomMessageEntry in the session.
|
||||
*/
|
||||
export type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, "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<ExecResult>;
|
||||
/** 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<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command registration options.
|
||||
*/
|
||||
export interface RegisteredCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
handler: (ctx: CommandContext) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ToolResultEvent, ToolResultEventResult | undefined>): 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<T = unknown>(message: HookMessage<T>, 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<T = unknown>(customType: string, data?: T): void;
|
||||
|
||||
/**
|
||||
* Register a custom renderer for CustomMessageEntry with a specific customType.
|
||||
* The renderer is called when rendering the entry in the TUI.
|
||||
* Return null to use the default renderer.
|
||||
*/
|
||||
renderCustomMessage<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): void;
|
||||
registerCustomMessageRenderer<T = unknown>(customType: string, renderer: CustomMessageRenderer<T>): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -341,7 +341,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
return definitions.map((def) => {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
|
||||
const customMessageRenderers = new Map<string, any>();
|
||||
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
|
||||
const commands = new Map<string, any>();
|
||||
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
|
||||
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
|
||||
|
||||
const api = {
|
||||
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
|
|
@ -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 ?? "<inline>",
|
||||
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;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -131,16 +131,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
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({
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue