From 9c9e6822e3af7e0c17cd4116b8eab01d79eb69a8 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sun, 4 Jan 2026 12:36:19 -0800 Subject: [PATCH] feat(coding-agent): add event bus for tool/hook communication (#431) * feat(coding-agent): add event bus for tool/hook communication Adds pi.events API enabling custom tools and hooks to communicate via pub/sub. Tools can emit events, hooks can listen. Shared EventBus instance created per session in createAgentSession(). - EventBus interface with emit() and on() methods - on() returns unsubscribe function - Threaded through hook and tool loaders - Documented in hooks.md and custom-tools.md * fix(coding-agent): wrap event handlers to catch errors * docs: note async handler error handling for event bus * feat(coding-agent): add sendMessage to tools, nextTurn delivery mode - Custom tools now have pi.sendMessage() for direct agent notifications - New deliverAs: 'nextTurn' queues messages for next user prompt - Fix: hooks and tools now share the same eventBus (was isolated before) * fix(coding-agent): nextTurn delivery should always queue, even when streaming --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/docs/custom-tools.md | 48 ++++++++++++++ packages/coding-agent/docs/hooks.md | 65 +++++++++++++++++-- .../coding-agent/src/core/agent-session.ts | 19 ++++-- .../src/core/custom-tools/loader.ts | 12 +++- .../src/core/custom-tools/types.ts | 34 ++++++++++ packages/coding-agent/src/core/event-bus.ts | 33 ++++++++++ .../coding-agent/src/core/hooks/loader.ts | 28 ++++++-- packages/coding-agent/src/core/hooks/types.ts | 30 +++++++-- packages/coding-agent/src/core/index.ts | 1 + packages/coding-agent/src/core/sdk.ts | 47 ++++++++++++-- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 5 +- 13 files changed, 293 insertions(+), 33 deletions(-) create mode 100644 packages/coding-agent/src/core/event-bus.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6e800aff..3674fc82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -43,6 +43,9 @@ - Tool registry now contains all built-in tools (read, bash, edit, write, grep, find, ls) even when `--tools` limits the initially active set. Hooks can enable any tool from the registry via `pi.setActiveTools()`. - System prompt now automatically rebuilds when tools change via `setActiveTools()`, updating tool descriptions and guidelines to match the new tool set - Hook errors now display full stack traces for easier debugging +- Event bus (`pi.events`) for tool/hook communication: shared pub/sub between custom tools and hooks +- Custom tools now have `pi.sendMessage()` to send messages directly to the agent session without needing the event bus +- `sendMessage()` supports `deliverAs: "nextTurn"` to queue messages for the next user prompt ### Changed diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 61061ef6..032c050a 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -159,6 +159,8 @@ interface CustomToolAPI { exec(command: string, args: string[], options?: ExecOptions): Promise; ui: ToolUIContext; hasUI: boolean; // false in --print or --mode rpc + events: EventBus; // Shared event bus for tool/hook communication + sendMessage(message, options?): void; // Send messages to the agent session } interface ToolUIContext { @@ -184,6 +186,52 @@ interface ExecResult { Always check `pi.hasUI` before using UI methods. +### Event Bus + +Tools can emit events that hooks (or other tools) listen for via `pi.events`: + +```typescript +// Emit an event +pi.events.emit("mytool:completed", { result: "success", itemCount: 42 }); + +// Listen for events (tools can also subscribe) +const unsubscribe = pi.events.on("other:event", (data) => { + console.log("Received:", data); +}); +``` + +Events are session-scoped. Use namespaced channel names like `"toolname:event"` to avoid collisions. + +Handler errors are caught and logged. For async handlers, handle errors internally: + +```typescript +pi.events.on("mytool:event", async (data) => { + try { + await doSomething(data); + } catch (err) { + console.error("Handler failed:", err); + } +}); +``` + +### Sending Messages + +Tools can send messages to the agent session via `pi.sendMessage()`: + +```typescript +pi.sendMessage({ + customType: "mytool-notify", + content: "Configuration was updated", + display: true, +}, { + deliverAs: "nextTurn", +}); +``` + +**Delivery modes:** `"steer"` (default) interrupts streaming, `"followUp"` waits for completion, `"nextTurn"` queues for next user message. Use `triggerTurn: true` to wake an idle agent immediately. + +See [hooks documentation](hooks.md#pisendmessagemessage-options) for full details. + ### Cancellation Example Pass the `signal` from `execute` to `pi.exec` to support cancellation: diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 23d585c1..9dbd511d 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -696,16 +696,33 @@ pi.sendMessage({ details: { ... }, // Optional metadata (not sent to LLM) }, { triggerTurn: true, // If true and agent is idle, triggers LLM response - deliverAs: "steer", // "steer" (default) or "followUp" when agent is streaming + deliverAs: "steer", // "steer", "followUp", or "nextTurn" }); ``` -**Storage and timing:** -- The message is appended to the session file immediately as a `CustomMessageEntry` -- If the agent is currently streaming: - - `deliverAs: "steer"` (default): Delivered after current tool execution, interrupts remaining tools - - `deliverAs: "followUp"`: Delivered only after agent finishes all work -- If `triggerTurn` is true and the agent is idle, a new agent loop starts +**Delivery modes (`deliverAs`):** + +| Mode | When agent is streaming | When agent is idle | +|------|------------------------|-------------------| +| `"steer"` (default) | Delivered after current tool, interrupts remaining | Appended to session immediately | +| `"followUp"` | Delivered after agent finishes all work | Appended to session immediately | +| `"nextTurn"` | Queued as context for next user message | Queued as context for next user message | + +The `"nextTurn"` mode is useful for notifications that shouldn't wake the agent but should be seen on the next turn. The message becomes an "aside" - included alongside the next user prompt as context, rather than appearing as a standalone entry or triggering immediate response. + +```typescript +// Example: Notify agent about tool changes without interrupting +pi.sendMessage( + { customType: "notify", content: "Tool configuration was updated", display: true }, + { deliverAs: "nextTurn" } +); +// On next user message, agent sees this as context +``` + +**`triggerTurn` option:** +- If `triggerTurn: true` and the agent is idle, a new agent loop starts immediately +- Ignored when streaming (use `deliverAs` to control timing instead) +- Ignored when `deliverAs: "nextTurn"` (the message waits for user input) **LLM context:** - `CustomMessageEntry` is converted to a user message when building context for the LLM @@ -869,6 +886,40 @@ pi.registerShortcut("shift+p", { Shortcut format: `modifier+key` where modifier can be `shift`, `ctrl`, `alt`, or combinations like `ctrl+shift`. +### pi.events + +Shared event bus for communication between hooks and custom tools. Tools can emit events, hooks can listen and wake the agent. + +```typescript +// Listen for events and wake agent when received +pi.events.on("task:complete", (data) => { + pi.sendMessage( + { customType: "task-notify", content: `Task done: ${data}`, display: true }, + { triggerTurn: true } // Required to wake the agent + ); +}); + +// Unsubscribe when needed +const unsubscribe = pi.events.on("my:channel", handler); +unsubscribe(); +``` + +Events are session-scoped (cleared when session ends). Channel names are arbitrary strings - use namespaced names like `"toolname:event"` to avoid collisions. + +Handler errors are caught and logged. For async handlers, handle errors internally: + +```typescript +pi.events.on("mytool:event", async (data) => { + try { + await doSomething(data); + } catch (err) { + console.error("Handler failed:", err); + } +}); +``` + +**Important:** Use `{ triggerTurn: true }` when you want the agent to respond to the event. Without it, the message displays but the agent stays idle. + ## Examples ### Permission Gate diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 7b81c242..d99c9d03 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -155,6 +155,8 @@ export class AgentSession { private _steeringMessages: string[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ private _followUpMessages: string[] = []; + /** Messages queued to be included with the next user prompt as context ("asides"). */ + private _pendingNextTurnMessages: HookMessage[] = []; // Compaction state private _compactionAbortController: AbortController | undefined = undefined; @@ -605,6 +607,12 @@ export class AgentSession { timestamp: Date.now(), }); + // Inject any pending "nextTurn" messages as context alongside the user message + for (const msg of this._pendingNextTurnMessages) { + messages.push(msg); + } + this._pendingNextTurnMessages = []; + // Emit before_agent_start hook event if (this._hookRunner) { const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); @@ -752,11 +760,11 @@ export class AgentSession { * * @param message Hook message with customType, content, display, details * @param options.triggerTurn If true and not streaming, triggers a new LLM turn - * @param options.deliverAs When streaming, use "steer" (default) for immediate or "followUp" to wait + * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" */ async sendHookMessage( message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { const appMessage = { role: "hookMessage" as const, @@ -766,18 +774,17 @@ export class AgentSession { details: message.details, timestamp: Date.now(), } satisfies HookMessage; - if (this.isStreaming) { - // Queue for processing by agent loop + if (options?.deliverAs === "nextTurn") { + this._pendingNextTurnMessages.push(appMessage); + } else if (this.isStreaming) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { - // Send as prompt - agent loop will emit message events await this.agent.prompt(appMessage); } else { - // Just append to agent state and session, no turn this.agent.appendMessage(appMessage); this.sessionManager.appendCustomMessageEntry( message.customType, diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index a3a0d6ee..b04c6f1c 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir, isBunBinary } from "../../config.js"; import { theme } from "../../modes/interactive/theme/theme.js"; +import { createEventBus, type EventBus } from "../event-bus.js"; import type { ExecOptions } from "../exec.js"; import { execCommand } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; @@ -213,10 +214,12 @@ export async function loadCustomTools( paths: string[], cwd: string, builtInToolNames: string[], + eventBus?: EventBus, ): Promise { const tools: LoadedCustomTool[] = []; const errors: Array<{ path: string; error: string }> = []; const seenNames = new Set(builtInToolNames); + const resolvedEventBus = eventBus ?? createEventBus(); // Shared API object - all tools get the same instance const sharedApi: CustomToolAPI = { @@ -225,6 +228,8 @@ export async function loadCustomTools( execCommand(command, args, options?.cwd ?? cwd, options), ui: createNoOpUIContext(), hasUI: false, + events: resolvedEventBus, + sendMessage: () => {}, }; for (const toolPath of paths) { @@ -259,6 +264,9 @@ export async function loadCustomTools( sharedApi.ui = uiContext; sharedApi.hasUI = hasUI; }, + setSendMessageHandler(handler) { + sharedApi.sendMessage = handler; + }, }; } @@ -303,12 +311,14 @@ function discoverToolsInDir(dir: string): string[] { * @param cwd - Current working directory * @param builtInToolNames - Names of built-in tools to check for conflicts * @param agentDir - Agent config directory. Default: from getAgentDir() + * @param eventBus - Optional shared event bus (creates isolated bus if not provided) */ export async function discoverAndLoadCustomTools( configuredPaths: string[], cwd: string, builtInToolNames: string[], agentDir: string = getAgentDir(), + eventBus?: EventBus, ): Promise { const allPaths: string[] = []; const seen = new Set(); @@ -335,5 +345,5 @@ export async function discoverAndLoadCustomTools( // 3. Explicitly configured paths (can override/add) addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd))); - return loadCustomTools(allPaths, cwd, builtInToolNames); + return loadCustomTools(allPaths, cwd, builtInToolNames, eventBus); } diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index df6093bb..80eb290b 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -10,8 +10,10 @@ import type { Model } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { EventBus } from "../event-bus.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; +import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; import type { ReadonlySessionManager } from "../session-manager.js"; @@ -34,6 +36,30 @@ export interface CustomToolAPI { ui: CustomToolUIContext; /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; + /** Shared event bus for tool/hook communication */ + events: EventBus; + /** + * Send a message to the agent session. + * + * Delivery behavior depends on agent state and options: + * - Streaming + "steer" (default): Interrupt mid-run, delivered after current tool. + * - Streaming + "followUp": Wait until agent finishes before delivery. + * - Idle + triggerTurn: Triggers a new LLM turn immediately. + * - Idle + "nextTurn": Queue to be included with the next user message as context. + * - Idle + neither: Append to session history as standalone entry. + * + * @param message - The message to send + * @param message.customType - Identifier for your tool + * @param message.content - Message content (string or TextContent/ImageContent array) + * @param message.display - Whether to show in TUI (true = styled display, false = hidden) + * @param message.details - Optional tool-specific metadata (not sent to LLM) + * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn + * @param options.deliverAs - Delivery mode: "steer", "followUp", or "nextTurn" + */ + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): void; } /** @@ -156,10 +182,18 @@ export interface LoadedCustomTool { tool: CustomTool; } +/** Send message handler type for tool sendMessage */ +export type ToolSendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, +) => void; + /** Result from loading custom tools */ export interface CustomToolsLoadResult { tools: LoadedCustomTool[]; errors: Array<{ path: string; error: string }>; /** Update the UI context for all loaded tools. Call when mode initializes. */ setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void; + /** Set the sendMessage handler for all loaded tools. Call when session initializes. */ + setSendMessageHandler(handler: ToolSendMessageHandler): void; } diff --git a/packages/coding-agent/src/core/event-bus.ts b/packages/coding-agent/src/core/event-bus.ts new file mode 100644 index 00000000..a82bfd09 --- /dev/null +++ b/packages/coding-agent/src/core/event-bus.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; + +export interface EventBus { + emit(channel: string, data: unknown): void; + on(channel: string, handler: (data: unknown) => void): () => void; +} + +export interface EventBusController extends EventBus { + clear(): void; +} + +export function createEventBus(): EventBusController { + const emitter = new EventEmitter(); + return { + emit: (channel, data) => { + emitter.emit(channel, data); + }, + on: (channel, handler) => { + const safeHandler = (data: unknown) => { + try { + handler(data); + } catch (err) { + console.error(`Event handler error (${channel}):`, err); + } + }; + emitter.on(channel, safeHandler); + return () => emitter.off(channel, safeHandler); + }, + clear: () => { + emitter.removeAllListeners(); + }, + }; +} diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index aad9cff7..f9c61e9f 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import type { KeyId } from "@mariozechner/pi-tui"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; +import { createEventBus, type EventBus } from "../event-bus.js"; import type { HookMessage } from "../messages.js"; import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; @@ -61,7 +62,7 @@ type HandlerFn = (...args: unknown[]) => Promise; */ export type SendMessageHandler = ( message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ) => void; /** @@ -221,6 +222,7 @@ function createHookAPI( handlers: Map, cwd: string, hookPath: string, + eventBus: EventBus, ): { api: HookAPI; messageRenderers: Map; @@ -292,7 +294,6 @@ function createHookAPI( options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { flags.set(name, { name, hookPath, ...options }); - // Set default value if provided if (options.default !== undefined) { flagValues.set(name, options.default); } @@ -309,6 +310,7 @@ function createHookAPI( ): void { shortcuts.set(shortcut, { shortcut, hookPath, ...options }); }, + events: eventBus, } as HookAPI; return { @@ -342,7 +344,11 @@ function createHookAPI( /** * Load a single hook module using jiti. */ -async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> { +async function loadHook( + hookPath: string, + cwd: string, + eventBus: EventBus, +): Promise<{ hook: LoadedHook | null; error: string | null }> { const resolvedPath = resolveHookPath(hookPath, cwd); try { @@ -376,7 +382,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo setGetAllToolsHandler, setSetActiveToolsHandler, setFlagValue, - } = createHookAPI(handlers, cwd, hookPath); + } = createHookAPI(handlers, cwd, hookPath, eventBus); // Call factory to register handlers factory(api); @@ -410,13 +416,15 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo * Load all hooks from configuration. * @param paths - Array of hook file paths * @param cwd - Current working directory for resolving relative paths + * @param eventBus - Optional shared event bus (creates isolated bus if not provided) */ -export async function loadHooks(paths: string[], cwd: string): Promise { +export async function loadHooks(paths: string[], cwd: string, eventBus?: EventBus): Promise { const hooks: LoadedHook[] = []; const errors: Array<{ path: string; error: string }> = []; + const resolvedEventBus = eventBus ?? createEventBus(); for (const hookPath of paths) { - const { hook, error } = await loadHook(hookPath, cwd); + const { hook, error } = await loadHook(hookPath, cwd, resolvedEventBus); if (error) { errors.push({ path: hookPath, error }); @@ -456,11 +464,17 @@ function discoverHooksInDir(dir: string): string[] { * 2. cwd/.pi/hooks/*.ts (project-local) * * Plus any explicitly configured paths from settings. + * + * @param configuredPaths - Explicitly configured hook paths + * @param cwd - Current working directory + * @param agentDir - Agent configuration directory + * @param eventBus - Optional shared event bus (creates isolated bus if not provided) */ export async function discoverAndLoadHooks( configuredPaths: string[], cwd: string, agentDir: string = getAgentDir(), + eventBus?: EventBus, ): Promise { const allPaths: string[] = []; const seen = new Set(); @@ -487,5 +501,5 @@ export async function discoverAndLoadHooks( // 3. Explicitly configured paths (can override/add) addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd))); - return loadHooks(allPaths, cwd); + return loadHooks(allPaths, cwd, eventBus); } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 0a817b1b..76d4f81d 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -10,6 +10,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mario import type { Component, KeyId, TUI } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; +import type { EventBus } from "../event-bus.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; @@ -747,15 +748,19 @@ export interface HookAPI { * @param message.content - Message content (string or TextContent/ImageContent array) * @param message.display - Whether to show in TUI (true = styled display, false = hidden) * @param message.details - Optional hook-specific metadata (not sent to LLM) - * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. + * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. + * Required for async patterns where you want the agent to respond. * If agent is streaming, message is queued and triggerTurn is ignored. - * @param options.deliverAs - How to deliver when agent is streaming. Default: "steer". - * - "steer": Interrupt mid-run, delivered after current tool execution. - * - "followUp": Wait until agent finishes all work before delivery. + * @param options.deliverAs - How to deliver the message. Default: "steer". + * - "steer": (streaming) Interrupt mid-run, delivered after current tool execution. + * - "followUp": (streaming) Wait until agent finishes all work before delivery. + * - "nextTurn": (idle) Queue to be included with the next user message as context. + * The message becomes an "aside" - context for the next turn without + * triggering a turn or appearing as a standalone entry. */ sendMessage( message: Pick, "customType" | "content" | "display" | "details">, - options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): void; /** @@ -899,6 +904,21 @@ export interface HookAPI { handler: (ctx: HookContext) => Promise | void; }, ): void; + + /** + * Shared event bus for tool/hook communication. + * Tools can emit events, hooks can listen for them. + * + * @example + * // Hook listening for events + * pi.events.on("subagent:complete", (data) => { + * pi.sendMessage({ customType: "notify", content: `Done: ${data.summary}` }); + * }); + * + * // Tool emitting events (in custom tool) + * pi.events.emit("my:event", { status: "complete" }); + */ + events: EventBus; } /** diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 4b15f6fe..675561d5 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -25,6 +25,7 @@ export { loadCustomTools, type RenderResultOptions, } from "./custom-tools/index.js"; +export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; export { type HookAPI, type HookContext, diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a812320f..022f7f92 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -43,6 +43,7 @@ import { wrapCustomTools, } from "./custom-tools/index.js"; import type { CustomTool } from "./custom-tools/types.js"; +import { createEventBus, type EventBus } from "./event-bus.js"; import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; import type { HookFactory } from "./hooks/types.js"; import { convertToLlm } from "./messages.js"; @@ -118,6 +119,9 @@ export interface CreateAgentSessionOptions { /** Pre-loaded hooks (skips loading, used when hooks were loaded early for CLI flags). */ preloadedHooks?: LoadedHook[]; + /** Shared event bus for tool/hook communication. Default: creates new bus. */ + eventBus?: EventBus; + /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ @@ -199,15 +203,19 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD /** * Discover hooks from cwd and agentDir. + * @param cwd - Current working directory + * @param agentDir - Agent configuration directory + * @param eventBus - Optional shared event bus (creates isolated bus if not provided) */ export async function discoverHooks( cwd?: string, agentDir?: string, + eventBus?: EventBus, ): Promise> { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); - const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir); + const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd, resolvedAgentDir, eventBus); // Log errors but don't fail for (const { path, error } of errors) { @@ -222,15 +230,25 @@ export async function discoverHooks( /** * Discover custom tools from cwd and agentDir. + * @param cwd - Current working directory + * @param agentDir - Agent configuration directory + * @param eventBus - Optional shared event bus (creates isolated bus if not provided) */ export async function discoverCustomTools( cwd?: string, agentDir?: string, + eventBus?: EventBus, ): Promise> { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); - const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools), resolvedAgentDir); + const { tools, errors } = await discoverAndLoadCustomTools( + [], + resolvedCwd, + Object.keys(allTools), + resolvedAgentDir, + eventBus, + ); // Log errors but don't fail for (const { path, error } of errors) { @@ -344,7 +362,10 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { /** * Convert hook definitions to LoadedHooks for the HookRunner. */ -function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { +function createLoadedHooksFromDefinitions( + definitions: Array<{ path?: string; factory: HookFactory }>, + eventBus: EventBus, +): LoadedHook[] { return definitions.map((def) => { const hookPath = def.path ?? ""; const handlers = new Map Promise>>(); @@ -401,6 +422,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa getActiveTools: () => getActiveToolsHandler(), getAllTools: () => getAllToolsHandler(), setActiveTools: (toolNames: string[]) => setActiveToolsHandler(toolNames), + events: eventBus, }; def.factory(api as any); @@ -484,6 +506,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); + const eventBus = options.eventBus ?? createEventBus(); // Use provided or create AuthStorage and ModelRegistry const authStorage = options.authStorage ?? discoverAuthStorage(agentDir); @@ -591,11 +614,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: loadedTools, errors: [], setUIContext: () => {}, + setSendMessageHandler: () => {}, }; } else { // Discover custom tools, merging with additional paths const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; - customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); + customToolsResult = await discoverAndLoadCustomTools( + configuredPaths, + cwd, + Object.keys(allTools), + agentDir, + eventBus, + ); time("discoverAndLoadCustomTools"); for (const { path, error } of customToolsResult.errors) { console.error(`Failed to load custom tool "${path}": ${error}`); @@ -608,13 +638,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} hookRunner = new HookRunner(options.preloadedHooks, cwd, sessionManager, modelRegistry); } else if (options.hooks !== undefined) { if (options.hooks.length > 0) { - const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); + const loadedHooks = createLoadedHooksFromDefinitions(options.hooks, eventBus); hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); } } else { // Discover hooks, merging with additional paths const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; - const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir); + const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir, eventBus); time("discoverAndLoadHooks"); for (const { path, error } of errors) { console.error(`Failed to load hook "${path}": ${error}`); @@ -755,6 +785,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }); time("createAgentSession"); + // Wire up sendMessage for custom tools + customToolsResult.setSendMessageHandler((msg, opts) => { + session.sendHookMessage(msg, opts); + }); + return { session, customToolsResult, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3989675a..c63497a6 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -48,6 +48,7 @@ export type { RenderResultOptions, } from "./core/custom-tools/index.js"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; +export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js"; export type * from "./core/hooks/index.js"; // Hook system types and type guards export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b266654d..66adbae1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -17,6 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; +import { createEventBus } from "./core/event-bus.js"; import { exportFromFile } from "./core/export-html/index.js"; import { discoverAndLoadHooks } from "./core/hooks/index.js"; import type { HookUIContext } from "./core/index.js"; @@ -303,8 +304,9 @@ export async function main(args: string[]) { // Early load hooks to discover their CLI flags const cwd = process.cwd(); const agentDir = getAgentDir(); + const eventBus = createEventBus(); const hookPaths = firstPass.hooks ?? []; - const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir); + const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir, eventBus); time("discoverHookFlags"); // Collect all hook flags @@ -402,6 +404,7 @@ export async function main(args: string[]) { const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks); sessionOptions.authStorage = authStorage; sessionOptions.modelRegistry = modelRegistry; + sessionOptions.eventBus = eventBus; // Handle CLI --api-key as runtime override (not persisted) if (parsed.apiKey) {