# 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`](https://gitea.jordbo.se/joakimp/pi-toolkit) (bring-up, keybindings, env loader) — that repo gets pi running; this repo adds behaviour on top of it. Also related to [`skillset`](https://gitea.jordbo.se/joakimp/skillset) which does the same job for agent skills. Read [`README.md`](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 `.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 /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/.ts` → `~/.pi/agent/extensions/.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](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions). 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 ` 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-.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-.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 `.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((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//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:** ```bash 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 `_` (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 `/reload`s. 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: ```bash ./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): ```bash # 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-.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. ## Related repos - [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) — pi bring-up: settings template, keybindings, shell env loader. Install this first. - [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) — persistent memory layer. Installs `mempalace.ts` into `~/.pi/agent/extensions/` separately from this repo. - [`skillset`](https://gitea.jordbo.se/joakimp/skillset) — same pattern for agent skills rather than extensions. - [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox) — Docker containers; composes toolkits via independent install.sh calls.