383 lines
12 KiB
Markdown
383 lines
12 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
1. **`setActiveTools`** — removes write tools from the active set entirely. The LLM doesn't even know they exist.
|
||
|
|
2. **`tool_call` guard** — even for allowed tools like `bash`, blocks destructive commands via an allowlist.
|
||
|
|
3. **`context` filter** — when exiting plan mode, removes stale plan mode context messages so they don't confuse the LLM in normal mode.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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:
|
||
|
|
- `bash` with `rm -rf` is 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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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_start` fires 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:
|
||
|
|
|
||
|
|
1. **Reads the persisted state** from `appendEntry`:
|
||
|
|
```typescript
|
||
|
|
const planModeEntry = entries
|
||
|
|
.filter(e => e.type === "custom" && e.customType === "plan-mode")
|
||
|
|
.pop();
|
||
|
|
```
|
||
|
|
|
||
|
|
2. **Re-scans assistant messages** for completion markers:
|
||
|
|
```typescript
|
||
|
|
// 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.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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`:
|
||
|
|
|
||
|
|
1. Skills → loaded, added to system prompt's skill listing
|
||
|
|
2. Prompts → loaded as prompt templates, available via `/templatename`
|
||
|
|
3. Themes → loaded, available via `/theme` or `ctx.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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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.
|