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

@ -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