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

@ -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<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): Promise<void> {
const appMessage = {
role: "hookMessage" as const,
@ -766,18 +774,17 @@ export class AgentSession {
details: message.details,
timestamp: Date.now(),
} satisfies HookMessage<T>;
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,