| name | pi-extension-authoring |
| description | WHAT: Conventions for adding or modifying a pi extension under `config/pi/extensions/` - the .ts + .md pair and where pure helpers / unit tests live. WHEN: the user asks to add a new pi extension, extract extension logic into a shared helper, or modify an existing extension's behavior. DO-NOT: put business logic in the .ts file (extract to `lib/node/pi/`); skip the companion .md doc; forget the `extensions/README.md` row; or make behavior tier-specific (small-model vs big-model branches).
|
Pi Extension Authoring
Pi extensions live under config/pi/extensions/. Each extension is a pair: a .ts file (the extension itself) and a
sibling .md file (the deep reference doc). Pure logic extracts into lib/node/pi/ so it's unit-testable under
tests/lib/node/pi/. This skill captures the repo-specific conventions on top of pi's public extension API.
When this applies
- Adding a new pi extension (new
.ts + .md pair).
- Extracting logic from an extension into a shared helper.
- Modifying an extension's detection / nudge / guardrail behavior.
- Wiring (or unwiring) an extension in
settings-baseline.json.
Skip this skill for config-only changes to settings-baseline.json that don't touch extension code.
File trio
Every extension is three things:
| File | Purpose |
|---|
config/pi/extensions/<name>.ts | The extension itself. Hooks, tool registrations, minimal glue. |
config/pi/extensions/<name>.md | Deep reference: detection rules, config shape, env vars, hot-reload notes. |
lib/node/pi/<helper>.ts | Pure helper(s) the extension calls into. Testable without pi's runtime. |
tests/lib/node/pi/<helper>.spec.ts (added with helper) | Vitest spec covering every branch of the helper. |
Wire the extension into config/pi/settings-baseline.json under the extensions array. Import paths are relative to
the baseline file.
The layering rule
Keep extensions thin. Every non-trivial behavior belongs in a pure function under lib/node/pi/.
- Extension
.ts: pi hooks (session_start, agent_end, tool registrations), plumbing, env var reads, and calls
out to the helper. Aim for under 100 lines.
- Helper
lib/node/pi/*.ts: the actual logic. Pure, deterministic, no pi API calls. One exported function per
concern. These get vitest specs.
- Helper spec
tests/lib/node/pi/*.spec.ts: covers every input/output pair the helper produces. Run with
npm test.
This layering is what lets the test suite run without spinning up pi. If you find yourself mocking pi inside a vitest
spec, the logic probably wants to move down into a helper.
Robustness, not tier switches
The robustness machinery in every extension carries the load across model tiers. Do NOT write if (model.isSmall) /
if (tier === 'weak') branches.
- Detection patterns (regex / heuristics) stay the same regardless of which model is running.
- Nudge text stays the same - don't "dumb down" for small models; a clear instruction is a clear instruction.
- When a feature genuinely needs a cheap model (e.g. a critic subagent's grader), use
modelOverride on the subagent
call site, not a tier switch inside the extension.
- See the companion memory
research-extensions-robustness-principle.
Tiny-model usage is opt-in and non-load-bearing
If the extension uses a local tiny model (e.g. a small quantized local model) for a subtask:
- Route through
runOneShotAgent (or a tiny-helper subagent) - never embed the model id directly.
- Gate via a setting that defaults off. When the tiny model is disabled or unavailable, the extension falls back to
deterministic behavior - never errors out.
- The tiny model must never touch user-visible research content or verification verdicts. It's for plumbing (heuristic
classification, phrase extraction) only.
- See the companion memory
research-tiny-model-non-load-bearing-rule.
Deep-doc (.md) shape
The .md file is the reference, not a tutorial. Mirror the shape of existing docs like verify-before-claim.md:
- One-sentence purpose - what failure mode the extension addresses.
- Composition table (when multiple extensions listen on the same hook) - how this extension distinguishes its
signal from siblings.
- Detection - the exact patterns / walks / heuristics. Link into the helper file.
- Rule / config shape - with a JSONC example when the extension supports per-project overrides.
- Environment variables -
PI_<NAME>_DISABLED, PI_<NAME>_VERBOSE, PI_<NAME>_TRACE=<path>. Standard trio;
include what applies.
- Hot reload - which files trigger
/reload, which require a session restart.
Update config/pi/extensions/README.md's index table in the same commit.
Smoke-testing against a local small model
After shipping a nudge / detection / guardrail, smoke-test against a local small / weak model to confirm it behaves
sensibly when tool-call precision is weak:
pi -p "<scenario prompt>" --model <provider/local-small-model> --no-session
Use whatever local small/weak model your setup runs; omit --model to smoke against the current model instead.
Scenarios to exercise:
- The detection positive case - does the extension fire?
- An explicit negative - confirm the check exempts legitimate work.
- The idempotency path - the sentinel-marker check means the same user message must NOT re-trigger the extension on a
retry.
For visual / critic extensions that call a critic subagent, confirm the critic can attach images (read <png>
auto-attaches on recognized extensions - png, jpg, gif, webp). Render SVGs to PNG with whatever rasterizer your
machine has (rsvg-convert or magick).
Record your own setup's specifics - the exact local model id, any proxy caveat, the installed SVG rasterizer - in a
personal note or memory rather than here, so this doc stays portable for anyone cloning the repo.
Multi-turn headless
A single pi -p is one turn. To drive a real multi-turn conversation headless (e.g. a guardrail that should fire on
turn 2, or summary / affinity state that accrues across turns) there are two ways:
RPC mode - preferred when an agent is driving the test. One long-lived pi --mode rpc process takes JSONL prompt
commands on stdin and streams events back as JSON lines on stdout. From inside pi, start it as a bg_bash job with
interactiveStdin: true, write one command per turn, and watch for the agent_end event that marks each turn done:
pi --mode rpc --model <provider/local-small-model> --no-session
# write to stdin, one JSON object per line:
{"id":"t1","type":"prompt","message":"turn 1"}
{"id":"t2","type":"prompt","message":"turn 2"} # same process, remembers turn 1
Context lives in the process for its lifetime, so --no-session is fine. It uses stdin / stdout pipes (no unix socket),
so the sandbox caveat below does not apply. Wait for agent_end before sending the next prompt; to enqueue while the
agent is still streaming, add "streamingBehavior":"steer" (delivered after the current tool calls) or "followUp"
(delivered when the agent stops). Shut down with SIGTERM. Full protocol: docs/rpc.md in the pi clone.
Session reuse - good for shell scripts / CI. Respawn pi -p against a persisted session:
DIR=$(mktemp -d)
pi --session-dir "$DIR" --session-id smoke -p "turn 1" --model <provider/local-small-model>
pi --session-dir "$DIR" --session-id smoke -p "turn 2"
rm -rf "$DIR"
--session-id <id> opens the session when one with that exact id already exists in the project, otherwise creates it;
it cannot be combined with --no-session. A throwaway --session-dir keeps the test isolated and easy to clean up.
(-c / --continue also continues a conversation, but it picks the most-recent session non-deterministically - prefer
--session-id for scripted tests.)
Driving the TUI with tmux
Interactive surfaces (statusline, header / footer widgets, avatar, keybindings) never render under -p. Script the TUI
in a detached tmux pane and assert on the captured screen:
SOCK=./.pi-tui-sock
tmux -S "$SOCK" new-session -d -x 120 -y 40 -s t "pi --model <provider/local-small-model>"
sleep 8
tmux -S "$SOCK" capture-pane -p -t t
tmux -S "$SOCK" send-keys -t t "your prompt" Enter
sleep <model-latency>
tmux -S "$SOCK" capture-pane -p -t t
tmux -S "$SOCK" send-keys -t t C-p
tmux -S "$SOCK" kill-server
Sandbox caveat (Linux): the sandbox extension's seccomp filter blocks tmux's unix socket, and the unixSockets.allow
path list is ignored on Linux (seccomp can't match by path). When you are about to run a tmux TUI test, ask the user to
disable the sandbox for the session (/sandbox-disable, or relaunch with PI_SANDBOX_DISABLED=1) - do not enable
unixSockets.allowAll, which would relax socket isolation for the whole session.
Wiring into settings-baseline.json
Add an entry under extensions:
{
"extensions": [
"./extensions/<name>.ts",
],
}
settings-baseline.json mirrors ~/.pi/agent/settings.json; keep runtime-only keys out of the baseline (e.g.
lastChangelogVersion). Run /reload in a live pi session to pick up extension changes without restarting.
Anti-patterns
- Logic in the
.ts extension file. Tests can't reach it without spinning up pi. Extract to lib/node/pi/.
- Tier-specific branches. Small-model vs big-model code paths. Use robust detection + gentle nudges instead.
- Tiny model on the hot path. If the extension errors when the local model is unreachable, it's load-bearing -
refactor to a deterministic fallback.
- Skipping the deep
.md doc. The .md is the contract for future readers. The extension index in
extensions/README.md links to it.
- No
PI_<NAME>_DISABLED escape hatch. Every extension should be silenceable by env var.
- Re-triggering on the extension's own nudge. Use a sentinel marker (e.g.
⚠ [pi-<name>]) on the injected message
and short-circuit when it's present on the most recent user message.
- Leaving the test gap - no
lib/node/pi/*.spec.ts for the helper. Small regressions in detection patterns destroy
the guardrail's value; tests catch those.
Checklist before finishing
.ts extension under config/pi/extensions/ - thin, no business logic.
.md deep doc with detection + config + env vars + hot-reload sections.
- Pure helpers extracted to
lib/node/pi/.
- Vitest spec under
tests/lib/node/pi/. npm test passes.
- Entry added to
extensions array in settings-baseline.json.
- Row added to
config/pi/extensions/README.md index table.
PI_<NAME>_DISABLED env var supported; smoke-tested against a local small model.
./dev/lint-shell.sh passes for any shell files touched.
- If a companion skill teaches WHEN to use this tool, add it under
config/pi/skills/<name>/SKILL.md and cross-link
from the .md and README-skills.md.