Files
pi-extensions/AGENTS.md
T
joakimp da34f41798 AGENTS: note that extension imports are coupled to host pi version
The 2026-05-09 rename of extension imports from @mariozechner/pi-* to
@earendil-works/pi-* surfaced a sequencing failure: extensions are
loaded by jiti at runtime, and their import statements resolve against
whatever node_modules the running pi binary bundles. A host pi still
on @mariozechner-bundle 0.73.1 cannot resolve @earendil-works/* and
fails extension load with 'Cannot find module'.

Bun build --external '*' does NOT catch this \u2014 it only validates the
bundle shape, not runtime module resolution. The actual gate is
running pi against the extensions on the target system.

Codify the rule in Conventions so future renames (or major-version
deps bumps) sequence the host upgrade alongside the import change.
2026-05-09 21:20:53 +02:00

20 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

  • 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 running pi binary 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 see Cannot find module '@earendil-works/...' at extension load. Container path: bump PI_VERSION build-arg in opencode-devbox. Host path: npm install -g @earendil-works/pi-coding-agent (and brew uninstall pi-coding-agent if 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: run pi --version on the target system after the rename.

  • 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/@earendil-works/pi-coding-agent/examples/extensions/todo.ts \
   ~/src/src_local/pi-extensions/extensions/todo.ts
# review diff, then commit

mcp-loader.ts

Generic MCP client — reads an mcp block from ~/.pi/agent/settings.json and connects to each declared server (stdio subprocess or streamable-HTTP endpoint), 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, two transports.

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.
  • Tool names are sanitized to ^[A-Za-z][A-Za-z0-9_]{0,63}$ before registering with pi. This is the strictest regex we have to satisfy: Anthropic's Messages API allows hyphens, but AWS Bedrock's Anthropic shim rejects them outright — a single hyphenated tool name causes the whole turn to 4xx silently, manifesting as "no output at all" once the offending server is enabled. context7 surfaces this because its tools are named resolve-library-id and query-docs. We replace any non-[A-Za-z0-9_] char with _, prepend t_ if the result doesn't start with a letter, and truncate to 64 chars. The original MCP-side name is kept in the closure used by client.callTool, so the sanitization is purely pi-facing.
  • Fail-soft per server. A server that won't start (binary missing, init handshake fails, remote 4xx/5xx) logs one stderr line and is skipped. Other servers continue. Pi keeps working.
  • Two transports behind one interface. IMcpClient (start, tools, callTool, stop) is implemented by StdioMcpClient (subprocess + newline-delimited JSON-RPC) and RemoteMcpClient (HTTP POST + JSON or SSE response). The extension entry dispatches on cfg.type and the rest of the code is transport-agnostic.
  • 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 stdio reconnect on death. If a stdio subprocess crashes mid-session, its tools become permanently unavailable until pi /reloads. Same limitation as the mempalace bridge today.
  • Remote sessions self-heal on 404. Per spec 2025-11-25 §2.2, a server that has terminated a session MUST respond with HTTP 404 to requests carrying the stale Mcp-Session-Id. RemoteMcpClient catches this condition (404 + sessionAtStart was non-null), drops the id, runs a fresh initialize + notifications/initialized, and retries the original request once. Persistent 404s after refresh surface as errors.

API used:

  • pi.registerTool({ name, label, description, parameters, execute }) for each MCP tool exposed by each connected server.
  • pi.on("session_shutdown", ...) to SIGTERM stdio subprocesses and DELETE remote sessions 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 clients:

  • class StdioMcpClient — spawn subprocess, write newline-delimited JSON-RPC, parse newline-delimited responses, match by request id. Stderr drained silently unless PI_MCP_LOADER_DEBUG=1.
  • class RemoteMcpClient — streamable-HTTP per MCP spec 2025-03-26. POST JSON-RPC body to a single URL with Accept: application/json, text/event-stream. Server replies either with one JSON response or an SSE stream containing event: message / data: <json> blocks; we consume the stream until the response with our id arrives, then cancel the reader. Optional Mcp-Session-Id header is captured on initialize and round-tripped on every subsequent request, then DELETEd on stop. Per-server headers config (e.g. Authorization) is merged into every request. No GET-stream subscription — server-initiated notifications are not consumed.
  • Both share interface IMcpClient { serverName, tools, start, callTool, stop }. Both send initialize handshake with protocolVersion: MCP_PROTOCOL_VERSION (currently 2025-11-25, per the constant at the top of the file), then notifications/initialized, then tools/list to discover tools.

Future extensions:

  • OAuth flow for remote servers that require it (today: pre-issued bearer tokens via headers only).
  • 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.