singularity-forge/docs/dev/context-and-hooks/06-advanced-patterns-from-source.md

383 lines
12 KiB
Markdown
Raw Normal View History

# 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.