pi has had MCP as a built-in extension (builtin:mcp) since 0.99.0. It reads ~/.pi/agent/mcp.json and adds `pi mcp add|list|login`, a /mcp manager, OAuth, per-tool exposure and resources. An extension that registers /mcp REPLACES the built-in, so with mcp-loader installed pi never read mcp.json and printed "registers command /mcp, so built-in extension mcp was not loaded" on every start. Measured on pi 1.1.0 (pi-devbox v1.11.0, tor-ms22) before removing it: - time to the (auto) footer in a real pty, 7 interleaved runs each: -ne 0.276 s | -ne -e mcp-loader.ts 0.283 s | -ne -e builtin:mcp 0.272 s - with no servers, the same prompt costs 79 input + 7680 cached tokens with the loader, the built-in, or neither - a stdio server (server-everything) connected via `pi mcp list` in 3 s, and its echo tool ran end to end from `pi -p` with direct exposure - no device in the fleet had an `mcp` block, so nothing needs migrating install.sh now prunes links into this repo whose target is gone (.ts and .ts.off), so the stale mcp-loader.ts link on a preserved pi-devbox volume is removed on the first boot of an image without the file. It also warns once if settings.json still carries an `mcp` block. Tested two-sided in a temp HOME: the new script removes the repo link and a stale .ts.off, and keeps a foreign dangling link and a real file. The old script leaves the dangling link. Re-runs are idempotent, and there is no warning without an mcp block. mempalace.ts (mempalace-toolkit) is unaffected: it registers mempalace_* tools itself. Its vendored RemoteMcpClient loses its sync partner; that is handled in mempalace-toolkit.
25 KiB
AGENTS.md
What this is
A version-controlled collection of custom and modified pi coding-agent extensions,
symlinked into ~/.pi/agent/extensions/ by install.sh.
Companion to pi-toolkit (bring-up,
keybindings, env loader) — that repo gets pi running; this repo adds behaviour on
top of it. Also related to skillset
which does the same job for agent skills.
Read README.md first for the user-facing walk-through.
This file is for agents modifying the repo.
Structure
extensions/
ssh-controlmaster.ts # ControlMaster SSH remote execution (see below)
confirm-destructive.ts # Confirm before dangerous bash commands and session actions
git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork
notify.ts # Native terminal notification when agent finishes
ext-toggle.ts # /ext slash command — list & toggle extensions at runtime
todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example)
task.ts # `task` tool — pi-task (isolated child, PASS/FAIL envelope, boundary diff) as a tool
fork-gate.ts # tool_call hook: block forks whose brief needs `task`; reason = the task(...) to make
aws-sso.ts # /aws-sso: renew the AWS SSO session when it ends (local: host browser; remote: device code + ntfy push)
secret-guard.ts # tool_result/context/tool_call hooks: secret env values never reach the model, session file or palace feed
test/ # node --test 'test/**/*.test.mjs' — two-sided tests for the pure parts (classifier, root validation, secret selection/redaction)
install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
package.json # pi package manifest — enables `pi install /path` as an alternative
README.md # User-facing docs.
AGENTS.md # This file.
LICENSE # MIT.
Conventions
-
Extension imports are coupled to the running pi version. Extensions are loaded by jiti at runtime; their
import { ... } from "@earendil-works/pi-*"statements resolve against whatever node_modules the runningpibinary has bundled. When extension imports change package names (e.g. the 2026-05-09 rename from@mariozechner/pi-*to@earendil-works/pi-*), the host pi MUST be upgraded to the matching version before or at the same time as the import-rename push, otherwise users on the older pi seeCannot find module '@earendil-works/...'at extension load. Container path: bumpPI_VERSIONbuild-arg in opencode-devbox. Host path:npm install -g @earendil-works/pi-coding-agent(andbrew uninstall pi-coding-agentif previously installed via brew at the old name).bun build --external '*'does NOT catch this regression — it only validates the bundle shape, not module resolution against the runtime pi's deps. Real test: runpi --versionon the target system after the rename. -
One
.tsfile per extension, flat underextensions/. If an extension grows multiple files, use a subdirectory with anindex.tsentry point — pi's auto-discovery handles both shapes. -
Extensions are symlinked, not copied.
ln -s <repo>/extensions/foo.ts ~/.pi/agent/extensions/foo.ts. Edits flow through git without a re-install step. The symlink is what pi loads at startup. -
Existing files at link destinations are backed up with a timestamp (
.bak.YYYYMMDD-HHMMSS) before a new symlink is created. Never silently overwrite. -
install.shis idempotent. Re-running it is always safe. Links already pointing into this repo are left alone (link_into_repoguard). -
--only/--skipflags for subset installs.--onlyis an explicit allowlist;--skipstarts with everything and removes named entries;--onlywins if both are given. Names are bare (no.ts). -
No always-on side effects at module load time. Extensions should be inert unless the user passes a flag or the relevant event fires. The
--sshflag inssh-controlmaster.tsis the model: the module loads on every pi session but does nothing unless--sshis present. -
Extension flag names must be globally unique across all installed extensions. Prefix with a short namespace if there's any risk of collision (e.g.
ssh-for ssh-related flags).
What install.sh does
require_pi_installed— aborts with exit 4 if~/.pi/agent/is missing. Creates~/.pi/agent/extensions/proactively.- Calls
build_install_setto resolve which extensions to install based on--only/--skipflags (default: all.tsfiles inextensions/). prune_stale_links(after the confirmation prompt) — removes links in~/.pi/agent/extensions/(*.tsand*.ts.off) that point into THIS repo and whose target no longer exists. That is how a retired extension leaves a preserved pi-devbox volume: the link outlives the file in every new image. Foreign links and real files are never touched.warn_retired_mcp_block(before the prompt, read-only) warns ifsettings.jsonstill has the retired loader's top-levelmcpkey.- For each selected extension: symlinks
extensions/<name>.ts→~/.pi/agent/extensions/<name>.ts. Backs up any pre-existing real file or foreign symlink.
Uninstall: removes symlinks that point into this repo, leaves everything else untouched.
Adding a new extension
- Drop a
.tsfile intoextensions/. No other config needed —install.shdiscovers all.tsfiles automatically. - Export a default factory function
(pi: ExtensionAPI) => void. See the pi extensions docs and built-in examples. - Register any CLI flags via
pi.registerFlag()so they appear inpi --help. - Keep the extension inert without its flag (or equivalent trigger). Don't run side effects unconditionally at load time.
- Update
README.md— add an entry under the Extensions section documenting flags, use cases, requirements, and how it works. - Re-run
./install.sh— it picks up the new file and symlinks it. In a running pi session,/reloadis enough; no restart needed.
Extension-specific notes
ssh-controlmaster.ts
Overrides the four native pi tools (read, write, edit, bash) to execute
on a remote machine via SSH when --ssh user@host is passed.
Key design decisions:
-
ControlMaster negotiation via
ssh -G.
Before starting any connection,readSshConfig(remote)runsssh -G <host>and inspects thecontrolmasterandcontrolpathfields. If the effective config already hasControlMaster autooryesand theControlPathdirectory is writable (controlPathWritable()expands~, then checks the socket's parent dir exists+writable, or is missing but creatable), the system socket is reused (ownsmaster: false). Otherwise — no system master, or a systemControlPathon a read-only mount (the devbox bind-mounts~/.sshread-only, so a user'sControlPath ~/.ssh/cm/...is unwritable) — the extension starts its own master at/tmp/pi-cm-<pid>.sock(ownsmaster: true); its command-line-o ControlPathoverrides the user's unwritable path. Thesession_shutdownhandler only callsssh -O exitwhenownsmasteris true — it never tears down a connection it didn't create. -
Read-only-
~/.sshsafe pwd probe.
The remote working dir is resolved withssh -o ControlPath=none -o ControlMaster=no <remote> pwd— a direct connection that ignores multiplexing so a read-only systemControlPath(which a plainssh … pwdwould try, and fail, to bind a master socket into) cannot make the initial probe exit 255. -
Hang-proofing (no ssh call can block the UI).
session_startisawaited by pi before the agent loop accepts input, so a blocking ssh call there manifests as "the TUI is up but my prompts are silently ignored." Guards: a sharedCONNECT_OPTS(ConnectTimeout=8+ServerAliveInterval=5/ServerAliveCountMax=3) is appended to every ssh invocation (pwd probe, master start,sshExec, bash exec); thepwdprobe usesrun(..., { timeoutMs: STARTUP_TIMEOUT_MS })(15 s) and both master-start paths kill the child + reject on the same wall-clock cap. Key-auth calls also carryBATCH_OPTS(BatchMode=yes) so ssh refuses any/dev/ttypassword/passphrase/host-key prompt rather than waiting on it invisibly;--ssh-ask-passomitsBatchMode(it would disableSSH_ASKPASS). TheaskPassflag is read before the probe so the probe picks the right opts. On probe failure the handler sets an✗ unreachablestatus + error toast andreturns (instead of throwing an unhandled rejection). Common trigger: a LAN host reachable from the macOS host but not from inside the pi-devbox container (pi-dev=docker compose … exec devbox pi); the container has no route without aProxyJump hostentry, sopi --ssh <lan-host>from the container now fails fast instead of stalling on the OS TCP timeout. -
LAN reachability via
-Fconfig detection (SSH_CONFIG_OPTS).
Every ssh call (ssh -G, pwd probe, master start,sshExec, bash exec,ssh -O exit) is prefixed with...SSH_CONFIG_OPTS, resolved once at module load byresolveSshConfigOpts():PI_SSH_CONFIGif set (leading~expanded, honored even if missing), else~/.ssh-local/configif it exists, else[]. This closes the gap wheredssh(ssh -F ~/.ssh-local/config) could reach LAN peers viaProxyJump hostbutpi --ssh— which shelled out to plainssh/ssh -Gagainst the default~/.ssh/config— could not. The sidecar config is regenerated by the devbox'ssetup-lan-access.shon every container start and includes the host-owned, read-only-mounted~/.config/devbox-shell/ssh-lan.conf(theProxyJump hostlist). No hostnames are baked into the image; on the host~/.ssh-local/configis absent so-Fis omitted and behavior is unchanged. Command-line-ooptions still win over the-Fconfig, so the own-master/tmpsocket path andControlMaster=yes/nodecisions are unaffected. Enrolling a new peer is a one-line host-side edit tossh-lan.conf(the peer must already be aHostblock in the host's~/.ssh/config, which supplies HostName/User/key). -
Password auth via
--ssh-ask-pass.
When the flag is set,ctx.ui.input()prompts for a password before connecting. The password is passed to SSH viaSSH_ASKPASS: a temp script at/tmp/pi-askpass-<pid>.sh(chmod 700) is written, SSH is spawned withSSH_ASKPASS+SSH_ASKPASS_REQUIRE=force(plusDISPLAY=dummyfor older OpenSSH), and the script is deleted in afinallyblock. Input is not masked — visible while typing. -
Path mapping.
Remote paths are derived by replacing the localcwdwithremoteCwdin every path argument. This works cleanly when pi is started from a directory that has a matching counterpart on the remote. Use theuser@host:/pathform when the paths diverge. -
ownsmastervs system master and--ssh-ask-pass.
If the system already has a ControlMaster configured for the target host and itsControlPathis writable,--ssh-ask-passis silently ignored — the system master handles auth independently and the socket is just reused. When the systemControlPathis unwritable (read-only~/.ssh) we start our own master instead, so the password flag is honored on that path.
confirm-destructive.ts
Always-on (no flag). Two layers:
-
Bash gate — intercepts
tool_callforbashand checks the command against a pattern list. On match, shows aselectdialog. Blocks in non-interactive mode by default. Patterns: recursiverm,sudo,chmod/chown 777,dd if=,mkfs,git push --force, writes to/dev/*,truncate --size 0. -
Session gate — hooks
session_before_switchandsession_before_fork. Confirms before/new(clear) and/resume(switch, only if there are unsaved messages). Always confirms before/fork.
When adding new patterns, add to the DANGEROUS array at the top of the file.
Each entry has a pattern (RegExp) and a label shown in the dialog.
git-checkpoint.ts
Always-on. Silently skips when the cwd is not inside a git repo.
- At
turn_start: runsgit stash create(non-destructive — creates a stash object without touching the working tree). Stores the stash ref keyed to the current session entry ID. Empty output (nothing to stash) is silently ignored. - At
session_before_fork: if a checkpoint exists for the selected entry, offers togit stash applyit. - Status bar shows
⎇ N checkpointswhen checkpoints are present. - Checkpoints are in-memory only — they do not survive a pi restart, but stash objects remain in the git repo and can be applied manually.
notify.ts
Always-on. Records agent_start timestamp; on agent_end checks if elapsed
time exceeds --notify-min-secs (default 8). Silently skips short responses.
Terminal detection order: KITTY_WINDOW_ID → OSC 99 (Kitty) →
WT_SESSION → Windows toast → OSC 777 (iTerm2, WezTerm, Ghostty).
Notification text: Pi — Done (Ns) where N is the rounded elapsed seconds.
ext-toggle.ts
Registers /ext slash command. Opens a multi-toggle overlay built on
SettingsList from pi-tui. Lists files in ~/.pi/agent/extensions/ with
● enabled / ○ disabled values. Space stages a toggle; Enter commits
all pending renames at once and calls ctx.reload(); Escape cancels.
UX rationale: the previous single-pick + immediate-apply flow made it awkward to flip several extensions in a row (every toggle reloaded the whole runtime). Stage-then-commit batches reloads to one per session.
Key design decisions:
- Rename, not delete. Disabling a built-in produces a
name.ts.offsymlink/file that's invisible to pi's*.tsdiscovery glob but trivially reversible. No state stored elsewhere. - Symlink-friendly.
fs.renameSyncrenames the symlink itself; the repo target is untouched. Toggling an extension installed by this repo is reversible without re-runninginstall.sh. install.shrespects the disabled state. When linking, the installer first checks for a<name>.ts.offsymlink pointing into this repo and skips re-linking if found. So re-running./install.sh(e.g. to pick up a newly added extension) does not silently re-enable a previously/ext-disabled extension.- Per-extension disable guards. A
DISABLE_GUARDSmap keyed by bare extension name lets specific extensions refuse a/extdisable when toggling would silently break in-flight session state. Currently used byssh-controlmaster: refuses to disable while--sshis inprocess.argv, because disabling tears down the ControlMaster (if we own it) and reverts read/write/edit/bash to the local filesystem while the system prompt still says we're on the remote. Add new entries here as similar foot-guns are discovered. - Subdir extensions are read-only in v1.
name/index.tsshapes show up in the listing with a[dir]tag but cannot be toggled — the cleanest disable for a directory would need a hidden-prefix or move-aside dance that adds more failure modes than it's worth for now. install.sh --uninstallmatches both*.tsand*.ts.off. Means a disabled extension is still cleaned up on uninstall, regardless of which state it was left in.- Listing is recomputed on every
/extinvocation. No cache, no event subscription — cheap enough for the tens of files this directory will ever contain.
API used:
pi.registerCommand(name, { description, handler })— registers/ext.ctx.ui.custom<T>((tui, theme, kb, done) => Component)— full-overlay with own keyboard handling. The wrapper component interceptsenterviamatchesKey(data, Key.enter)before forwarding toSettingsList, which would otherwise consume Enter for value-cycling.SettingsListfrom pi-tui — the list itself. Cycles values on space (and on enter, but enter is intercepted upstream).onChangefires per cycle and is where staging happens.getSettingsListTheme()from pi-coding-agent — themed colors.ctx.ui.notify(message, level)— toast for post-commit status / errors.ctx.reload()— same reload as/reloadcommand.
No flags, no agent_* event handlers — fully passive until /ext is invoked.
secret-guard.ts
Tier 1 of mempalace-toolkit's bin/mempalace_redact.py, ported to TypeScript and
applied at the step that redactor cannot reach: tool output on its way to the model.
Read the module header for the incident and for what is not covered.
- The selection rules are a copy, and must stay one.
SECRET_NAME,NAME_NOT_SECRET,TRIVIAL_VALUESandNOT_A_SECRET_VALUEmirror the Python_SECRET_NAME,_NAME_NOT_SECRET,_TRIVIAL_VALUESand_NOT_A_SECRET_VALUE. Change both together. The parity tests run the Python module on the same synthetic env and fail on any difference in selected names or output text; they SKIP (not pass) whenmempalace_redact.pyis absent, so run them where it is installed (/opt/mempalace-toolkit/bin, or pointMEMPALACE_REDACT_PYat it). - Tier 1 only, on purpose. Matching a known value cannot hit anything that is not that secret, so it is safe on every tool result. Shape rules (tier 2) would catch secrets that are not in env, but they rewrite text by pattern. In tool output that means a misfire changes what the agent reads and may write back. Add them only with the same two-sided tests.
tool_resultreturningcontentdropsstructuredContentunless it is returned too (pitypes.d.ts).toolResultChangealways returns it when content changed; a test pins that.- Handlers return
undefinedwhen nothing matched.redactDeepkeeps object identity for untouched values so this is cheap to detect. A handler that always returned a copy would rewrite every tool result and every request context, for nothing. - Hits are counted per field, then the highest count per name is reported.
pi's bash returns its output twice (content text and
structuredContent.output), so a sum would report oneechoas "×2". - The
contextpass walks the whole conversation on every request. Measured: 36 ms on a 53 MB session, under 1 ms on a typical one. - End-to-end check (2026-10-12, real pi on erdc/deepseek-v4.1-flash; the secret
was synthetic, and a probe extension on
before_provider_requestcounted it in each payload):- control without the guard: 1 raw copy sent to the provider, 1 in the session file;
- with the guard: 0 sent, 0 in the session file, placeholder in both;
- resumed control session: 1 sent without the guard, 0 with it;
- user
!echo $SECRET: 0 sent, plus a TUI warning; - write of the placeholder: blocked, file sha unchanged.
The raw value still showed in
--mode jsonstdout, intool_execution_update(live progress), which is not covered.
todo.ts
Unchanged copy of upstream examples/extensions/todo.ts from
pi-coding-agent (the homebrew install at /opt/homebrew/Cellar/...).
Provides the agent with a todo tool (actions: list/add/toggle/clear)
and registers /todos for the user to inspect the list.
Why copied, not symlinked: symlinking would point at
/opt/homebrew/Cellar/pi-coding-agent/<version>/libexec/... which
rotates on every brew upgrade — fragile. Copy keeps it stable; we lose
upstream updates but gain reproducibility.
State persistence: the agent stores todo state in tool result
details, not an external file. Two useful properties: state survives
pi --continue/--resume because it lives in the session JSONL, and
/fork correctly forks the todo list along with the conversation.
Refresh from upstream when needed:
cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@earendil-works/pi-coding-agent/examples/extensions/todo.ts \
~/src/src_local/pi-extensions/extensions/todo.ts
# review diff, then commit
mcp-loader.ts — RETIRED 2026-10-12
Removed in favour of pi's built-in MCP support (builtin:mcp, pi ≥ 0.99.0:
~/.pi/agent/mcp.json, pi mcp add|list|login, the /mcp manager, OAuth,
per-tool exposure, resources). Reasons, measured on pi 1.1.0 before removal
(README "MCP servers" has the numbers):
- The two could not coexist. pi treats an extension that registers
/mcpas a replacement for the built-in, so with the loader installedmcp.jsonwas never read and every start printed… registers command /mcp, so built-in extension mcp was not loaded. - No cost to switching. Time-to-ready is the same within noise (0.283 s vs 0.272 s), and with no servers configured the built-in adds zero tokens to a request.
- The fleet had no
mcpblock anywhere (checked on tor-ms22; all pi-devbox devices share one configuration), so nothing needed migrating.
What survives elsewhere: mempalace-toolkit/extensions/pi/mempalace.ts
carries a vendored copy of the loader's RemoteMcpClient (streamable-HTTP).
That copy is now the only one, so there is no longer a sync partner for its
MCP-STREAMABLE-HTTP-CLIENT-SYNC token. The design notes below the old
loader's header — tool-name sanitisation for Bedrock, fail-soft per server,
404 session re-init — live on in git history (git show a88fa99:extensions/mcp-loader.ts).
Do not reintroduce an extension that registers /mcp, codemode or
tool_search without meaning to replace the built-in: pi then drops the
built-in of the same name, with nothing but one startup warning to show for it.
Testing
No framework. Manual:
./install.sh --help
./install.sh --yes # fresh install
./install.sh --yes # re-run (idempotent — all "already linked")
./install.sh --only ssh-controlmaster --yes # subset
./install.sh --skip ssh-controlmaster --yes # inverse subset
./install.sh --uninstall --yes # remove
./install.sh --yes # reinstall
Manual extension tests (requires a reachable SSH host):
# Key-based auth
pi --ssh user@host
# Explicit remote path
pi --ssh user@host:/etc
# Password auth
pi --ssh user@host --ssh-ask-pass
# Verify status bar shows ⚡ own master or ⚡ system master
# Verify /reload works without restarting pi
Gotchas
ctx.ui.input()does not mask input. Passwords typed via--ssh-ask-passare visible in the terminal. A masked alternative would require a customctx.ui.custom()component.- Socket path length limit on macOS. Unix socket paths are capped at ~104
characters.
/tmp/pi-cm-<pid>.sockis safe. Don't use paths under$HOMEwhich can be long. ssh -Gstrips theuser@prefix before querying config. ThereadSshConfighelper does this automatically. Don't passuser@hostdirectly tossh -G.SSH_ASKPASS_REQUIRE=forcerequires OpenSSH 8.4+.DISPLAY=dummyis set as a fallback for older versions. Both are needed for full compatibility.- Extensions loaded globally affect every pi session. Extensions without
an activating flag (e.g. a
session_starthook that always fires) will run on every invocation. Keep that surface small. pi install /pathandinstall.shsymlinks are mutually exclusive for the same extension.pi installmanages its own copy;install.shmanages a symlink. Pick one per machine.
Related repos
pi-toolkit— pi bring-up: settings template, keybindings, shell env loader. Install this first.mempalace-toolkit— persistent memory layer. Installsmempalace.tsinto~/.pi/agent/extensions/separately from this repo.skillset— same pattern for agent skills rather than extensions.opencode-devbox— Docker containers; composes toolkits via independent install.sh calls.
Documentation drift sweep
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
README.md— the per-extension feature/usage descriptions, the install instructions, the dependency list. When an extension's flags or behaviour change, the README block for that extension is the first thing to update.AGENTS.md(this file) — theExtension-specific notessection is large and easy to leave stale. Each extension has its own subsection; if you change a flag, hook, or default, update the subsection here as well as the README.- Extension files themselves — the
// description:comment header pi parses for the extension list view must stay accurate. install.sh—--only/--skipdefaults, the list of artifacts it knows about, and the symlink targets. If you add a new extension file, the installer's auto-discovery picks it up; if you add a new artifact type that needs special handling, the script must be updated and the README block describing whatinstall.sh doesalong with it.
Quick triage: git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md install.sh. For an extension rename, also grep for the old name across the whole repo.