Files added by PR #2008 that were not in main were dropped during the merge. Restore all src/, docs/, and scripts/ files from the pre-merge PR head. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7.4 KiB
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
// 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
// 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.
// 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:
// 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:
// 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:
// 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:
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.