Files
pi-extensions/AGENTS.md
T
joakimp 7eec49b9b8 mcp-loader: add /mcp slash command for runtime status + toggle
Mirrors /ext UX (space=stage, enter=apply+reload, esc=cancel) but for
MCP servers in the settings.json `mcp` block. Tracks per-server runtime
state captured at extension load time so users can see at a glance
which servers are running / failed / disabled / remote-skipped /
invalid, with tool counts for the running ones.

Toggling writes back to settings.json — disabling sets enabled:false,
re-enabling removes the explicit key (default is true) to keep the
file tidy. Then ctx.reload() picks up the change.

Closes the visibility gap surfaced by 'searxng_search isn't in /ext':
MCP-provided tools are runtime-spawned, not file-based extensions, so
they need their own list view. /mcp fills that hole.
2026-05-08 21:05:09 +02:00

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 + /mcp slash command
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 .ts file per extension, flat under extensions/. If an extension grows multiple files, use a subdirectory with an index.ts entry 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.sh is idempotent. Re-running it is always safe. Links already pointing into this repo are left alone (link_into_repo guard).
  • --only / --skip flags for subset installs. --only is an explicit allowlist; --skip starts with everything and removes named entries; --only wins 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 --ssh flag in ssh-controlmaster.ts is the model: the module loads on every pi session but does nothing unless --ssh is 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

  1. require_pi_installed — aborts with exit 4 if ~/.pi/agent/ is missing. Creates ~/.pi/agent/extensions/ proactively.
  2. Calls build_install_set to resolve which extensions to install based on --only / --skip flags (default: all .ts files in extensions/).
  3. 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

  1. Drop a .ts file into extensions/. No other config needed — install.sh discovers all .ts files automatically.
  2. Export a default factory function (pi: ExtensionAPI) => void. See the pi extensions docs and built-in examples.
  3. Register any CLI flags via pi.registerFlag() so they appear in pi --help.
  4. Keep the extension inert without its flag (or equivalent trigger). Don't run side effects unconditionally at load time.
  5. Update README.md — add an entry under the Extensions section documenting flags, use cases, requirements, and how it works.
  6. Re-run ./install.sh — it picks up the new file and symlinks it. In a running pi session, /reload is 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) runs ssh -G <host> and inspects the controlmaster and controlpath fields. If the effective config already has ControlMaster auto or yes, the system socket is reused (ownsmaster: false). Otherwise, the extension starts its own master at /tmp/pi-cm-<pid>.sock (ownsmaster: true). The session_shutdown handler only calls ssh -O exit when ownsmaster is 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 via SSH_ASKPASS: a temp script at /tmp/pi-askpass-<pid>.sh (chmod 700) is written, SSH is spawned with SSH_ASKPASS + SSH_ASKPASS_REQUIRE=force (plus DISPLAY=dummy for older OpenSSH), and the script is deleted in a finally block. Input is not masked — visible while typing.

  • Path mapping.
    Remote paths are derived by replacing the local cwd with remoteCwd in every path argument. This works cleanly when pi is started from a directory that has a matching counterpart on the remote. Use the user@host:/path form when the paths diverge.

  • ownsmaster vs system master and --ssh-ask-pass.
    If the system already has a ControlMaster configured for the target host, --ssh-ask-pass is silently ignored — the system master handles auth independently and the socket is just reused.

confirm-destructive.ts

Always-on (no flag). Two layers:

  1. Bash gate — intercepts tool_call for bash and checks the command against a pattern list. On match, shows a select dialog. Blocks in non-interactive mode by default. Patterns: recursive rm, sudo, chmod/chown 777, dd if=, mkfs, git push --force, writes to /dev/*, truncate --size 0.

  2. Session gate — hooks session_before_switch and session_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: runs git 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 to git stash apply it.
  • Status bar shows ⎇ N checkpoints when 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.off symlink/file that's invisible to pi's *.ts discovery glob but trivially reversible. No state stored elsewhere.
  • Symlink-friendly. fs.renameSync renames the symlink itself; the repo target is untouched. Toggling an extension installed by this repo is reversible without re-running install.sh.
  • install.sh respects the disabled state. When linking, the installer first checks for a <name>.ts.off symlink 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_GUARDS map keyed by bare extension name lets specific extensions refuse a /ext disable when toggling would silently break in-flight session state. Currently used by ssh-controlmaster: refuses to disable while --ssh is in process.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.ts shapes 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 --uninstall matches both *.ts and *.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 /ext invocation. 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 intercepts enter via matchesKey(data, Key.enter) before forwarding to SettingsList, which would otherwise consume Enter for value-cycling.
  • SettingsList from pi-tui — the list itself. Cycles values on space (and on enter, but enter is intercepted upstream). onChange fires 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 /reload command.

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 mcp block 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_NAME that's worth preserving. Loader and bridge coexist. Listing mempalace in the mcp block 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 request id.
  • Sends initialize handshake with protocolVersion: 2024-11-05, then notifications/initialized, then tools/list to 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: {...}} without required).

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-pass are visible in the terminal. A masked alternative would require a custom ctx.ui.custom() component.
  • Socket path length limit on macOS. Unix socket paths are capped at ~104 characters. /tmp/pi-cm-<pid>.sock is safe. Don't use paths under $HOME which can be long.
  • ssh -G strips the user@ prefix before querying config. The readSshConfig helper does this automatically. Don't pass user@host directly to ssh -G.
  • SSH_ASKPASS_REQUIRE=force requires OpenSSH 8.4+. DISPLAY=dummy is 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_start hook that always fires) will run on every invocation. Keep that surface small.
  • pi install /path and install.sh symlinks are mutually exclusive for the same extension. pi install manages its own copy; install.sh manages a symlink. Pick one per machine.
  • pi-toolkit — pi bring-up: settings template, keybindings, shell env loader. Install this first.
  • mempalace-toolkit — persistent memory layer. Installs mempalace.ts into ~/.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.