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.
12 KiB
Advanced Patterns from Source
Production patterns extracted from the pi codebase, built-in extensions, and real extension examples. Each pattern shows the mechanism, the source of truth, and when to use it.
Pattern 1: Mode-Aware Tool Sets with Context Injection
Source: plan-mode/index.ts — the built-in plan mode extension.
This pattern combines tool set management, tool call blocking, context event filtering, and before_agent_start injection into a cohesive mode system.
The Architecture
/plan toggle → sets planModeEnabled
├─► setActiveTools(PLAN_MODE_TOOLS) # restrict available tools
├─► tool_call guard # block unsafe bash even if tool is active
├─► before_agent_start # inject mode-specific instructions
├─► context # filter stale mode messages on mode exit
└─► agent_end # check plan output, offer execution
Key Insight: Defense in Depth
The plan mode uses THREE layers of tool control:
setActiveTools— removes write tools from the active set entirely. The LLM doesn't even know they exist.tool_callguard — even for allowed tools likebash, blocks destructive commands via an allowlist.contextfilter — when exiting plan mode, removes stale plan mode context messages so they don't confuse the LLM in normal mode.
// Layer 1: Tool set
if (planModeEnabled) {
pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
}
// Layer 2: Bash guard
pi.on("tool_call", async (event) => {
if (!planModeEnabled || event.toolName !== "bash") return;
if (!isSafeCommand(event.input.command)) {
return { block: true, reason: "Plan mode: command blocked" };
}
});
// Layer 3: Context cleanup on mode exit
pi.on("context", async (event) => {
if (planModeEnabled) return; // keep plan context when in plan mode
return {
messages: event.messages.filter(m => {
// Remove plan mode markers from context
if (m.customType === "plan-mode-context") return false;
return true;
}),
};
});
Why This Matters
A naive implementation would just change the tool set. But:
bashwithrm -rfis technically a "read-only" tool by name- Stale context messages from a previous mode can confuse the LLM
- The LLM might try to work around restrictions if it sees the mode instructions but has the tools available
Pattern 2: Preset System with Dynamic Model + Tool + Prompt Configuration
Source: preset.ts — the built-in preset extension.
This pattern shows how to build a full configuration management system that coordinates model, thinking level, tools, and system prompt from a single config file.
The Architecture
presets.json → load on session_start
│
├─► /preset command → applyPreset(name)
├─► Ctrl+Shift+U → cyclePreset()
├─► --preset flag → applyPreset on startup
│
applyPreset:
├─► pi.setModel() → switch model
├─► pi.setThinkingLevel() → adjust thinking
├─► pi.setActiveTools() → reconfigure tools
└─► store activePreset → before_agent_start reads it
before_agent_start:
└─► append preset.instructions to system prompt
Key Insight: Deferred System Prompt Application
The preset doesn't modify the system prompt during applyPreset. It stores activePreset and lets before_agent_start read it:
// On apply — just store
activePresetName = name;
activePreset = preset;
// On each prompt — inject
pi.on("before_agent_start", async (event) => {
if (activePreset?.instructions) {
return {
systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`,
};
}
});
This is better than calling agent.setSystemPrompt() directly because:
before_agent_startfires on every prompt, keeping the system prompt current- The base system prompt is rebuilt by pi when tools change — a direct set would be overwritten
- Other extensions can see and further modify the prompt in the chain
Pattern 3: Progress Tracking with Widget + State Persistence
Source: plan-mode/index.ts — todo item tracking during plan execution.
The Architecture
Plan created (assistant message with "Plan:" section)
→ extractTodoItems() parses numbered steps
→ todoItems stored in memory
→ ui.setWidget() shows progress
→ appendEntry() persists state
Each turn:
→ turn_end checks for [DONE:n] markers
→ markCompletedSteps() updates todoItems
→ updateStatus() refreshes widget
Session resume:
→ session_start restores from appendEntry
→ Re-scans messages after last execute marker for [DONE:n]
→ Rebuilds completion state
Key Insight: Dual State Reconstruction
On session resume, the extension does TWO things:
-
Reads the persisted state from
appendEntry:const planModeEntry = entries .filter(e => e.type === "custom" && e.customType === "plan-mode") .pop(); -
Re-scans assistant messages for completion markers:
// Only scan messages AFTER the last plan-mode-execute marker const allText = messages.map(getTextContent).join("\n"); markCompletedSteps(allText, todoItems);
This handles the case where the extension crashed or was reloaded mid-execution — the persisted state might be stale, but the messages are the source of truth.
Pattern 4: Dynamic Resource Injection
Source: dynamic-resources/index.ts — extension that ships its own skills and themes.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const baseDir = dirname(fileURLToPath(import.meta.url));
export default function (pi: ExtensionAPI) {
pi.on("resources_discover", () => {
return {
skillPaths: [join(baseDir, "SKILL.md")],
promptPaths: [join(baseDir, "dynamic.md")],
themePaths: [join(baseDir, "dynamic.json")],
};
});
}
How It Works Internally
After session_start, the runner calls emitResourcesDiscover(). The returned paths are processed through the ResourceLoader:
- Skills → loaded, added to system prompt's skill listing
- Prompts → loaded as prompt templates, available via
/templatename - Themes → loaded, available via
/themeorctx.ui.setTheme()
The system prompt is rebuilt after resources are extended, so new skills appear in the same prompt turn.
When to Use
- Extension packages that need custom skills (e.g., a deployment extension with a "deploy checklist" skill)
- Theme packs distributed as extensions
- Dynamic prompt templates that depend on the project context
Pattern 5: Claude Rules Integration
Source: claude-rules.ts — scanning .claude/rules/ for per-project rules.
The Architecture
session_start:
→ Scan .claude/rules/ for .md files (recursive)
→ Store file list
before_agent_start:
→ Append file list to system prompt
→ Agent uses read tool to load specific rules on demand
Key Insight: Listing, Not Loading
The extension does NOT load rule file contents into the system prompt. It lists the files:
pi.on("before_agent_start", async (event) => {
if (ruleFiles.length === 0) return;
const rulesList = ruleFiles.map(f => `- .claude/rules/${f}`).join("\n");
return {
systemPrompt: event.systemPrompt + `
## Project Rules
The following project rules are available in .claude/rules/:
${rulesList}
When working on tasks related to these rules, use the read tool to load the relevant rule files.`,
};
});
This is context-efficient: the system prompt grows by one line per rule file, not by the full contents of every rule. The LLM loads specific rules via read only when relevant.
Pattern 6: Remote Execution via Tool Wrapping
Source: The SSH extension pattern and createBashTool with pluggable operations.
The Architecture
Tools support pluggable operations that replace the underlying I/O:
import { createBashTool } from "@mariozechner/pi-coding-agent";
// Create a bash tool that executes via SSH
const remoteBash = createBashTool(cwd, {
operations: {
execute: async (command, options) => {
return sshExec(remoteHost, command, options);
},
},
});
// Register it as the bash tool (overrides built-in)
pi.registerTool({
...remoteBash,
name: "bash", // same name = overrides built-in
});
The spawnHook Alternative
For lighter customization (e.g., environment setup):
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd: `/mnt/sandbox${cwd}`,
env: { ...env, CI: "1" },
}),
});
User Bash Hook for ! Commands
The user_bash event lets you intercept user-typed bash commands (not LLM-initiated ones):
pi.on("user_bash", async (event) => {
// Route user bash commands through SSH too
return {
operations: {
execute: (cmd, opts) => sshExec(remoteHost, cmd, opts),
},
};
});
Pattern 7: Extension-Aware Compaction
Source: session_before_compact in agent-session.ts.
Custom Compaction Summary
Override the default LLM-generated summary:
pi.on("session_before_compact", async (event) => {
// Build a domain-specific summary
const summary = buildCustomSummary(event.branchEntries);
return {
compaction: {
summary,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
};
});
Compaction-Aware State
If your extension stores state in messages that might get compacted away, you need a reconstruction strategy:
pi.on("session_start", async (_event, ctx) => {
// Check if there's been a compaction
const entries = ctx.sessionManager.getBranch();
const hasCompaction = entries.some(e => e.type === "compaction");
if (hasCompaction) {
// State before compaction is gone from messages
// Fall back to appendEntry data or re-derive from remaining messages
restoreFromAppendEntries(entries);
} else {
// Full message history available
restoreFromToolResults(entries);
}
});
Pattern 8: The Complete Extension Initialization Sequence
From the source code, the full initialization order is:
1. Extension factory function runs
├─► pi.on() — register event handlers
├─► pi.registerTool() — register tools
├─► pi.registerCommand() — register commands
├─► pi.registerShortcut() — register shortcuts
├─► pi.registerFlag() — register CLI flags
└─► pi.registerProvider() — queued (not yet applied)
2. ExtensionRunner created with all extensions
3. bindCore() — action methods become live
├─► pi.sendMessage, pi.setActiveTools, etc. now work
└─► Queued provider registrations flushed to ModelRegistry
4. bindExtensions() — UI context and command context connected
└─► setUIContext(), bindCommandContext()
5. session_start event fires
└─► Extensions restore state from session
6. resources_discover event fires
└─► Extensions provide additional skill/prompt/theme paths
7. System prompt rebuilt with new resources
8. Ready for first user prompt
Important timing: During step 1, action methods (sendMessage, setActiveTools, etc.) will throw. You can only register handlers and tools during the factory function. Use session_start for anything that needs runtime access.