singularity-forge/docs/dev/context-and-hooks/06-advanced-patterns-from-source.md
Jeremy 872b0adb48 docs: reorganize into user-docs/ and dev/ subdirectories
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.
2026-04-10 09:25:31 -05:00

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:

  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.
// 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:

// 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:

    const planModeEntry = entries
      .filter(e => e.type === "custom" && e.customType === "plan-mode")
      .pop();
    
  2. 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:

  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:

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.