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
This commit is contained in:
Nico Bailon 2026-01-04 12:36:19 -08:00 committed by GitHub
parent 12805f61bd
commit 9c9e6822e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 293 additions and 33 deletions

View file

@ -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<CustomToolsLoadResult> {
const tools: LoadedCustomTool[] = [];
const errors: Array<{ path: string; error: string }> = [];
const seenNames = new Set<string>(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<CustomToolsLoadResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
@ -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);
}