Reads an `mcp` block from ~/.pi/agent/settings.json (same shape as
opencode and Claude Desktop) and connects to each declared MCP server,
exposing all of their tools to pi as native tools namespaced as
<server-name>_<tool-name>.
Why: pi has no built-in MCP loader. Adding each new MCP server as a
hand-rolled extension (the way mempalace.ts does it) doesn't scale.
This is the config-driven generalization — one extension, any number
of servers, no per-server boilerplate.
Settings.json schema matches opencode and Claude Desktop verbatim:
{
"mcp": {
"searxng": {
"type": "local",
"command": ["uvx", "mcp-searxng"],
"env": { "SEARXNG_URL": "https://searxng.your-host.lan" }
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp"
}
}
}
Per-server keys: type (local/remote), command, url, enabled, env.
Implementation:
• StdioMcpClient class spawns subprocess, performs MCP initialize
handshake (protocol 2024-11-05), lists tools, exposes a callTool()
method. Newline-delimited JSON-RPC over stdio.
• Each MCP tool registered via pi.registerTool with the server-
namespaced name, the upstream MCP inputSchema passed through
via Type.Unsafe (TypeBox is JSON-Schema-compatible at runtime).
• Per-server fail-soft: a server that won't start logs one stderr
line and is skipped; others continue.
• SIGTERM all subprocesses on session_shutdown so /reload doesn't
leak processes.
Tool naming: prefix with <serverName>_ except when the upstream tool
name already starts with that prefix (mempalace's tools are already
mempalace_search, mempalace_kg_query, etc — avoids double-prefixing).
Coexists with mempalace.ts but does not replace it. The mempalace
bridge has bespoke agent-identity injection that's worth preserving.
v1 limitations:
• Stdio transport only. Remote (streamable-HTTP) servers are
detected and skipped with a warning. v2 will add streamable-HTTP.
• No reconnect on subprocess death — same limitation as mempalace.ts.
Verification:
• node --check syntax clean
• Standalone smoke test against `uvx mcp-server-time`: handshake +
tools/list (2 tools) + tools/call (get_current_time) all green
on the same JSON-RPC code that lives inside the loader.
Debug: set PI_MCP_LOADER_DEBUG=1 to surface per-server stderr.
18 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)
mcp-loader.ts # Generic MCP server loader — reads `mcp` block from settings.json
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
- 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/). - 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 autooryes, the system socket is reused (ownsmaster: false). Otherwise, the extension starts its own master at/tmp/pi-cm-<pid>.sock(ownsmaster: true). Thesession_shutdownhandler only callsssh -O exitwhenownsmasteris true — it never tears down a connection it didn't create. -
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,--ssh-ask-passis silently ignored — the system master handles auth independently and the socket is just reused.
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.
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/@mariozechner/pi-coding-agent/examples/extensions/todo.ts \
~/src/src_local/pi-extensions/extensions/todo.ts
# review diff, then commit
mcp-loader.ts
Generic MCP stdio client — reads an mcp block from ~/.pi/agent/settings.json
and connects to each declared server, exposing the tools as namespaced pi tools.
Why this exists: pi has no built-in MCP loader (unlike opencode and Claude
Desktop). Adding each new MCP server as a hand-rolled extension (the way
mempalace.ts does it) doesn't scale past 2-3 servers. This loader is the
config-driven generalization — one extension, any number of servers.
Key design decisions:
- Settings.json shape matches opencode / Claude Desktop verbatim. A user
can copy-paste their
mcpblock from one harness's config to the other. - Tool names are prefixed with the server name to avoid collisions when
multiple servers expose tools with the same short name. Tools that already
start with
<servername>_(the convention some servers like mempalace follow internally) skip the prefix to avoid double-prefixing. - Fail-soft per server. A server that won't start (binary missing, init handshake fails) logs one stderr line and is skipped. Other servers continue. Pi keeps working.
- Stdio transport only in v1. Remote/streamable-HTTP servers are detected and skipped with a warning. Adding remote support is the obvious v2 (context7 is the prime motivator). Avoided in v1 because streamable-HTTP needs SSE parsing, session management, and reconnect logic that triples the implementation size.
- Does not replace
mempalace.ts. The mempalace bridge has bespoke agent-identity injection from$MEMPALACE_AGENT_NAMEthat's worth preserving. Loader and bridge coexist. Listingmempalacein themcpblock would produce duplicate tool registrations — don't. - No reconnect on subprocess death. If a server's subprocess crashes
mid-session, its tools become permanently unavailable until pi
/reloads. Same limitation as the mempalace bridge today; not worth complicating v1.
API used:
pi.registerTool({ name, label, description, parameters, execute })for each MCP tool exposed by each connected server.pi.on("session_shutdown", ...)to SIGTERM all subprocesses cleanly.Type.Unsafe<...>(inputSchema)from typebox to pass the MCP server's JSON schema through to pi without conversion (TypeBox schemas are plain JSON Schema at runtime).
Internal MCP client:
class StdioMcpClient— spawn subprocess, write newline-delimited JSON-RPC, parse newline-delimited responses, match by requestid.- Sends
initializehandshake withprotocolVersion: 2024-11-05, thennotifications/initialized, thentools/listto discover tools. - Stderr drained silently unless
PI_MCP_LOADER_DEBUG=1.
Future v2 extensions:
- Streamable-HTTP transport for remote servers (context7).
- Per-tool enable/disable in settings.json (e.g.
"mcp.searxng.tools": ["web_search"]to expose only a subset). - Reconnect-on-crash with exponential backoff.
- Schema sanitization for MCP servers that emit malformed
inputSchema(some return{type: "object", properties: {...}}withoutrequired).
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.