Split flat docs/ into user-docs/ (guides, config, troubleshooting) and dev/ (ADRs, architecture, extension guides, proposals). Updated docs/README.md index to reflect new paths.
233 lines
7.4 KiB
Markdown
233 lines
7.4 KiB
Markdown
# Inter-Extension Communication
|
|
|
|
How extensions communicate with each other, share state, and coordinate behavior.
|
|
|
|
---
|
|
|
|
## pi.events — The Shared Event Bus
|
|
|
|
Every extension receives the same `pi.events` instance. It's a simple typed pub/sub bus.
|
|
|
|
### API
|
|
|
|
```typescript
|
|
// Emit an event on a channel
|
|
pi.events.emit("my-channel", { action: "started", id: 123 });
|
|
|
|
// Subscribe to a channel — returns an unsubscribe function
|
|
const unsub = pi.events.on("my-channel", (data) => {
|
|
// data is typed as `unknown` — you must cast
|
|
const payload = data as { action: string; id: number };
|
|
console.log(payload.action); // "started"
|
|
});
|
|
|
|
// Later: stop listening
|
|
unsub();
|
|
```
|
|
|
|
### Characteristics
|
|
|
|
| Property | Behavior |
|
|
|---|---|
|
|
| **Typing** | `data` is `unknown`. No generics. Cast at the consumer. |
|
|
| **Error handling** | Handlers are wrapped in async try/catch. Errors log to `console.error` but don't propagate to emitter or crash the session. |
|
|
| **Ordering** | Handlers fire in subscription order (order of `pi.events.on` calls). |
|
|
| **Persistence** | No replay, no persistence. If you emit before anyone subscribes, the event is lost. |
|
|
| **Scope** | Shared across ALL extensions in the session. The bus is created once and passed to every extension's `createExtensionAPI`. |
|
|
| **Lifecycle** | The bus is cleared on extension reload (`/reload`). Subscriptions from the old extension instances are gone. |
|
|
|
|
### Example: Extension A Signals Extension B
|
|
|
|
```typescript
|
|
// Extension A: plan-mode.ts
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.registerCommand("plan", {
|
|
handler: async (_args, ctx) => {
|
|
planEnabled = !planEnabled;
|
|
pi.events.emit("mode-change", { mode: planEnabled ? "plan" : "normal" });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Extension B: status-display.ts
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.events.on("mode-change", (data) => {
|
|
const { mode } = data as { mode: string };
|
|
// React to mode change
|
|
});
|
|
}
|
|
```
|
|
|
|
### Limitations
|
|
|
|
- **No request/response** — emit is fire-and-forget. If you need a response, use shared state or a callback pattern.
|
|
- **No guaranteed delivery** — if the subscriber hasn't loaded yet (load order matters), the event is missed.
|
|
- **No channel namespacing** — use descriptive channel names to avoid collisions (e.g., `"myext:event"` rather than `"update"`).
|
|
|
|
---
|
|
|
|
## Shared State Patterns
|
|
|
|
### Pattern 1: Shared Module State
|
|
|
|
If two extensions are loaded from the same package (via `package.json` `pi.extensions` array), they can share state through module-level variables in a shared file.
|
|
|
|
```
|
|
my-extension/
|
|
├── package.json # pi.extensions: ["./a.ts", "./b.ts"]
|
|
├── a.ts # import { state } from "./shared.ts"
|
|
├── b.ts # import { state } from "./shared.ts"
|
|
└── shared.ts # export const state = { count: 0 }
|
|
```
|
|
|
|
**Caveat:** jiti module caching means the shared module is loaded once. But on `/reload`, everything is re-imported from scratch — shared state resets.
|
|
|
|
### Pattern 2: Event Bus as State Channel
|
|
|
|
Use `pi.events` to broadcast state changes. Each extension maintains its own copy.
|
|
|
|
```typescript
|
|
// Extension A: authoritative state owner
|
|
let items: string[] = [];
|
|
|
|
function addItem(item: string) {
|
|
items.push(item);
|
|
pi.events.emit("items:updated", { items: [...items] });
|
|
}
|
|
|
|
// Extension B: state consumer
|
|
let mirroredItems: string[] = [];
|
|
|
|
pi.events.on("items:updated", (data) => {
|
|
mirroredItems = (data as { items: string[] }).items;
|
|
});
|
|
```
|
|
|
|
### Pattern 3: Session Entries as Coordination Points
|
|
|
|
Extensions can read each other's `appendEntry` data from the session:
|
|
|
|
```typescript
|
|
// Extension A writes:
|
|
pi.appendEntry("ext-a-config", { theme: "dark", verbose: true });
|
|
|
|
// Extension B reads during session_start:
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
if (entry.type === "custom" && entry.customType === "ext-a-config") {
|
|
const config = entry.data as { theme: string; verbose: boolean };
|
|
// Use config from Extension A
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
**Downside:** This only works after `session_start`. Not suitable for real-time coordination during a turn.
|
|
|
|
---
|
|
|
|
## Multi-Extension Coordination Patterns
|
|
|
|
### Pattern: Mode Manager
|
|
|
|
One extension acts as the mode authority, others react:
|
|
|
|
```typescript
|
|
// mode-manager.ts — the authority
|
|
export default function (pi: ExtensionAPI) {
|
|
let currentMode: "plan" | "execute" | "review" = "execute";
|
|
|
|
pi.registerCommand("mode", {
|
|
handler: async (args, ctx) => {
|
|
const newMode = args.trim() as typeof currentMode;
|
|
if (!["plan", "execute", "review"].includes(newMode)) {
|
|
ctx.ui.notify(`Invalid mode: ${newMode}`, "error");
|
|
return;
|
|
}
|
|
currentMode = newMode;
|
|
pi.events.emit("mode:changed", { mode: currentMode });
|
|
ctx.ui.notify(`Mode: ${currentMode}`);
|
|
},
|
|
});
|
|
|
|
// Other extensions can query current mode via event
|
|
pi.events.on("mode:query", () => {
|
|
pi.events.emit("mode:current", { mode: currentMode });
|
|
});
|
|
}
|
|
|
|
// tool-guard.ts — reacts to mode changes
|
|
export default function (pi: ExtensionAPI) {
|
|
let currentMode = "execute";
|
|
|
|
pi.events.on("mode:changed", (data) => {
|
|
currentMode = (data as { mode: string }).mode;
|
|
});
|
|
|
|
pi.on("tool_call", async (event) => {
|
|
if (currentMode === "plan" && ["edit", "write"].includes(event.toolName)) {
|
|
return { block: true, reason: "Plan mode: write operations disabled" };
|
|
}
|
|
if (currentMode === "review" && event.toolName === "bash") {
|
|
return { block: true, reason: "Review mode: bash disabled" };
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
### Pattern: Extension Priority Chain
|
|
|
|
When multiple extensions handle the same hook, load order determines priority. Project-local extensions load before global ones. Within a directory, files are discovered in filesystem order.
|
|
|
|
If you need explicit priority control:
|
|
|
|
```typescript
|
|
// priority-extension.ts
|
|
export default function (pi: ExtensionAPI) {
|
|
// Register with a known channel so other extensions can defer
|
|
pi.events.emit("priority:registered", { name: "security-guard" });
|
|
|
|
pi.on("tool_call", async (event) => {
|
|
// This runs first if loaded first
|
|
if (isUnsafe(event)) {
|
|
return { block: true, reason: "Security policy violation" };
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## The ExtensionContext in Tools
|
|
|
|
Tools registered by extensions receive `ExtensionContext` as their 5th `execute` parameter. This is the same context event handlers get:
|
|
|
|
```typescript
|
|
pi.registerTool({
|
|
name: "my_tool",
|
|
// ...
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
// ctx.ui — dialog methods, notifications, widgets
|
|
// ctx.sessionManager — read session state
|
|
// ctx.model — current model
|
|
// ctx.cwd — working directory
|
|
// ctx.hasUI — false in print/json mode
|
|
// ctx.isIdle() — agent state
|
|
// ctx.abort() — abort current operation
|
|
// ctx.getContextUsage() — token usage
|
|
// ctx.compact() — trigger compaction
|
|
// ctx.getSystemPrompt() — current system prompt
|
|
|
|
if (ctx.hasUI) {
|
|
const confirmed = await ctx.ui.confirm("Proceed?", "This will modify files");
|
|
if (!confirmed) {
|
|
return { content: [{ type: "text", text: "Cancelled by user" }] };
|
|
}
|
|
}
|
|
|
|
// ... do work
|
|
},
|
|
});
|
|
```
|
|
|
|
**Important:** The `ctx` is freshly created via `runner.createContext()` for each tool execution. It reflects the current state at call time (current model, current session, etc.), not the state when the tool was registered.
|