7eec49b9b8
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.
369 lines
18 KiB
Markdown
369 lines
18 KiB
Markdown
# 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 + /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](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 <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:**
|
|
|
|
```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 `<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 `/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-<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.
|
|
|
|
## 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.
|