750b2a89c8
pi has had MCP as a built-in extension (builtin:mcp) since 0.99.0. It reads ~/.pi/agent/mcp.json and adds `pi mcp add|list|login`, a /mcp manager, OAuth, per-tool exposure and resources. An extension that registers /mcp REPLACES the built-in, so with mcp-loader installed pi never read mcp.json and printed "registers command /mcp, so built-in extension mcp was not loaded" on every start. Measured on pi 1.1.0 (pi-devbox v1.11.0, tor-ms22) before removing it: - time to the (auto) footer in a real pty, 7 interleaved runs each: -ne 0.276 s | -ne -e mcp-loader.ts 0.283 s | -ne -e builtin:mcp 0.272 s - with no servers, the same prompt costs 79 input + 7680 cached tokens with the loader, the built-in, or neither - a stdio server (server-everything) connected via `pi mcp list` in 3 s, and its echo tool ran end to end from `pi -p` with direct exposure - no device in the fleet had an `mcp` block, so nothing needs migrating install.sh now prunes links into this repo whose target is gone (.ts and .ts.off), so the stale mcp-loader.ts link on a preserved pi-devbox volume is removed on the first boot of an image without the file. It also warns once if settings.json still carries an `mcp` block. Tested two-sided in a temp HOME: the new script removes the repo link and a stale .ts.off, and keeps a foreign dangling link and a real file. The old script leaves the dangling link. Re-runs are idempotent, and there is no warning without an mcp block. mempalace.ts (mempalace-toolkit) is unaffected: it registers mempalace_* tools itself. Its vendored RemoteMcpClient loses its sync partner; that is handled in mempalace-toolkit.
454 lines
25 KiB
Markdown
454 lines
25 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)
|
||
task.ts # `task` tool — pi-task (isolated child, PASS/FAIL envelope, boundary diff) as a tool
|
||
fork-gate.ts # tool_call hook: block forks whose brief needs `task`; reason = the task(...) to make
|
||
aws-sso.ts # /aws-sso: renew the AWS SSO session when it ends (local: host browser; remote: device code + ntfy push)
|
||
secret-guard.ts # tool_result/context/tool_call hooks: secret env values never reach the model, session file or palace feed
|
||
test/ # node --test 'test/**/*.test.mjs' — two-sided tests for the pure parts (classifier, root validation, secret selection/redaction)
|
||
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. `prune_stale_links` (after the confirmation prompt) — removes links in
|
||
`~/.pi/agent/extensions/` (`*.ts` and `*.ts.off`) that point into THIS repo
|
||
and whose target no longer exists. That is how a retired extension leaves a
|
||
preserved pi-devbox volume: the link outlives the file in every new image.
|
||
Foreign links and real files are never touched. `warn_retired_mcp_block`
|
||
(before the prompt, read-only) warns if `settings.json` still has the
|
||
retired loader's top-level `mcp` key.
|
||
4. 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/earendil-works/pi/blob/main/docs/extensions.md)
|
||
and [built-in examples](https://github.com/earendil-works/pi/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` **and the `ControlPath`
|
||
directory is writable** (`controlPathWritable()` expands `~`, then checks the
|
||
socket's parent dir exists+writable, or is missing but creatable), the system
|
||
socket is reused (`ownsmaster: false`). Otherwise — no system master, **or** a
|
||
system `ControlPath` on a read-only mount (the devbox bind-mounts `~/.ssh`
|
||
read-only, so a user's `ControlPath ~/.ssh/cm/...` is unwritable) — the
|
||
extension starts its own master at `/tmp/pi-cm-<pid>.sock` (`ownsmaster:
|
||
true`); its command-line `-o ControlPath` overrides the user's unwritable
|
||
path. The `session_shutdown` handler only calls `ssh -O exit` when
|
||
`ownsmaster` is true — it never tears down a connection it didn't create.
|
||
|
||
- **Read-only-`~/.ssh` safe pwd probe.**
|
||
The remote working dir is resolved with `ssh -o ControlPath=none -o
|
||
ControlMaster=no <remote> pwd` — a direct connection that ignores multiplexing
|
||
so a read-only system `ControlPath` (which a plain `ssh … pwd` would try, and
|
||
fail, to bind a master socket into) cannot make the initial probe exit 255.
|
||
|
||
- **Hang-proofing (no ssh call can block the UI).**
|
||
`session_start` is `await`ed by pi before the agent loop accepts input, so a
|
||
blocking ssh call there manifests as *"the TUI is up but my prompts are
|
||
silently ignored."* Guards: a shared `CONNECT_OPTS` (`ConnectTimeout=8` +
|
||
`ServerAliveInterval=5`/`ServerAliveCountMax=3`) is appended to **every** ssh
|
||
invocation (pwd probe, master start, `sshExec`, bash exec); the `pwd` probe
|
||
uses `run(..., { timeoutMs: STARTUP_TIMEOUT_MS })` (15 s) and both master-start
|
||
paths kill the child + reject on the same wall-clock cap. Key-auth calls also
|
||
carry `BATCH_OPTS` (`BatchMode=yes`) so ssh refuses any `/dev/tty`
|
||
password/passphrase/host-key prompt rather than waiting on it invisibly;
|
||
`--ssh-ask-pass` omits `BatchMode` (it would disable `SSH_ASKPASS`). The
|
||
`askPass` flag is read **before** the probe so the probe picks the right opts.
|
||
On probe failure the handler sets an `✗ unreachable` status + error toast and
|
||
`return`s (instead of throwing an unhandled rejection). Common trigger: a LAN
|
||
host reachable from the macOS host but **not** from inside the pi-devbox
|
||
container (`pi-dev` = `docker compose … exec devbox pi`); the container has no
|
||
route without a `ProxyJump host` entry, so `pi --ssh <lan-host>` from the
|
||
container now fails fast instead of stalling on the OS TCP timeout.
|
||
|
||
- **LAN reachability via `-F` config detection (`SSH_CONFIG_OPTS`).**
|
||
Every ssh call (`ssh -G`, pwd probe, master start, `sshExec`, bash exec,
|
||
`ssh -O exit`) is prefixed with `...SSH_CONFIG_OPTS`, resolved once at module
|
||
load by `resolveSshConfigOpts()`: `PI_SSH_CONFIG` if set (leading `~`
|
||
expanded, honored even if missing), else `~/.ssh-local/config` if it exists,
|
||
else `[]`. This closes the gap where `dssh` (`ssh -F ~/.ssh-local/config`)
|
||
could reach LAN peers via `ProxyJump host` but `pi --ssh` — which shelled out
|
||
to plain `ssh`/`ssh -G` against the default `~/.ssh/config` — could not. The
|
||
sidecar config is regenerated by the devbox's `setup-lan-access.sh` on every
|
||
container start and includes the host-owned, read-only-mounted
|
||
`~/.config/devbox-shell/ssh-lan.conf` (the `ProxyJump host` list). **No
|
||
hostnames are baked into the image**; on the host `~/.ssh-local/config` is
|
||
absent so `-F` is omitted and behavior is unchanged. Command-line `-o`
|
||
options still win over the `-F` config, so the own-master `/tmp` socket path
|
||
and `ControlMaster=yes/no` decisions are unaffected. Enrolling a new peer is a
|
||
one-line host-side edit to `ssh-lan.conf` (the peer must already be a `Host`
|
||
block in the host's `~/.ssh/config`, which supplies HostName/User/key).
|
||
|
||
- **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
|
||
*and its `ControlPath` is writable*, `--ssh-ask-pass` is silently ignored —
|
||
the system master handles auth independently and the socket is just reused.
|
||
When the system `ControlPath` is unwritable (read-only `~/.ssh`) we start our
|
||
own master instead, so the password flag is honored on that path.
|
||
|
||
### `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.
|
||
|
||
### `secret-guard.ts`
|
||
|
||
Tier 1 of mempalace-toolkit's `bin/mempalace_redact.py`, ported to TypeScript and
|
||
applied at the step that redactor cannot reach: tool output on its way to the model.
|
||
Read the module header for the incident and for what is not covered.
|
||
|
||
- **The selection rules are a copy, and must stay one.** `SECRET_NAME`,
|
||
`NAME_NOT_SECRET`, `TRIVIAL_VALUES` and `NOT_A_SECRET_VALUE` mirror the Python
|
||
`_SECRET_NAME`, `_NAME_NOT_SECRET`, `_TRIVIAL_VALUES` and `_NOT_A_SECRET_VALUE`.
|
||
Change both together. The parity tests run the Python module on the same
|
||
synthetic env and fail on any difference in selected names or output text; they
|
||
SKIP (not pass) when `mempalace_redact.py` is absent, so run them where it is
|
||
installed (`/opt/mempalace-toolkit/bin`, or point `MEMPALACE_REDACT_PY` at it).
|
||
- **Tier 1 only, on purpose.** Matching a known value cannot hit anything that is
|
||
not that secret, so it is safe on every tool result. Shape rules (tier 2) would
|
||
catch secrets that are not in env, but they rewrite text by pattern. In tool
|
||
output that means a misfire changes what the agent reads and may write back.
|
||
Add them only with the same two-sided tests.
|
||
- **`tool_result` returning `content` drops `structuredContent` unless it is
|
||
returned too** (pi `types.d.ts`). `toolResultChange` always returns it when
|
||
content changed; a test pins that.
|
||
- **Handlers return `undefined` when nothing matched.** `redactDeep` keeps object
|
||
identity for untouched values so this is cheap to detect. A handler that
|
||
always returned a copy would rewrite every tool result and every request
|
||
context, for nothing.
|
||
- **Hits are counted per field, then the highest count per name is reported.**
|
||
pi's bash returns its output twice (content text and `structuredContent.output`),
|
||
so a sum would report one `echo` as "×2".
|
||
- **The `context` pass walks the whole conversation on every request.** Measured:
|
||
36 ms on a 53 MB session, under 1 ms on a typical one.
|
||
- **End-to-end check** (2026-10-12, real pi on erdc/deepseek-v4.1-flash; the secret
|
||
was synthetic, and a probe extension on `before_provider_request` counted it in
|
||
each payload):
|
||
- control without the guard: 1 raw copy sent to the provider, 1 in the session file;
|
||
- with the guard: 0 sent, 0 in the session file, placeholder in both;
|
||
- resumed control session: 1 sent without the guard, 0 with it;
|
||
- user `!echo $SECRET`: 0 sent, plus a TUI warning;
|
||
- write of the placeholder: blocked, file sha unchanged.
|
||
The raw value still showed in `--mode json` stdout, in `tool_execution_update`
|
||
(live progress), which is not covered.
|
||
|
||
### `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/@earendil-works/pi-coding-agent/examples/extensions/todo.ts \
|
||
~/src/src_local/pi-extensions/extensions/todo.ts
|
||
# review diff, then commit
|
||
```
|
||
|
||
### `mcp-loader.ts` — RETIRED 2026-10-12
|
||
|
||
Removed in favour of pi's built-in MCP support (`builtin:mcp`, pi ≥ 0.99.0:
|
||
`~/.pi/agent/mcp.json`, `pi mcp add|list|login`, the `/mcp` manager, OAuth,
|
||
per-tool `exposure`, resources). Reasons, measured on pi 1.1.0 before removal
|
||
(README "MCP servers" has the numbers):
|
||
|
||
- **The two could not coexist.** pi treats an extension that registers `/mcp`
|
||
as a *replacement* for the built-in, so with the loader installed `mcp.json`
|
||
was never read and every start printed `… registers command /mcp, so
|
||
built-in extension mcp was not loaded`.
|
||
- **No cost to switching.** Time-to-ready is the same within noise (0.283 s
|
||
vs 0.272 s), and with no servers configured the built-in adds zero tokens
|
||
to a request.
|
||
- **The fleet had no `mcp` block anywhere** (checked on tor-ms22; all pi-devbox
|
||
devices share one configuration), so nothing needed migrating.
|
||
|
||
What survives elsewhere: `mempalace-toolkit/extensions/pi/mempalace.ts`
|
||
carries a vendored copy of the loader's `RemoteMcpClient` (streamable-HTTP).
|
||
That copy is now the only one, so there is no longer a sync partner for its
|
||
`MCP-STREAMABLE-HTTP-CLIENT-SYNC` token. The design notes below the old
|
||
loader's header — tool-name sanitisation for Bedrock, fail-soft per server,
|
||
404 session re-init — live on in git history (`git show a88fa99:extensions/mcp-loader.ts`).
|
||
|
||
Do **not** reintroduce an extension that registers `/mcp`, `codemode` or
|
||
`tool_search` without meaning to replace the built-in: pi then drops the
|
||
built-in of the same name, with nothing but one startup warning to show for it.
|
||
|
||
## Testing
|
||
|
||
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.
|
||
|
||
## Documentation drift sweep
|
||
|
||
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
|
||
|
||
- `README.md` — the per-extension feature/usage descriptions, the install instructions, the dependency list. When an extension's flags or behaviour change, the README block for that extension is the first thing to update.
|
||
- `AGENTS.md` (this file) — the `Extension-specific notes` section is large and easy to leave stale. Each extension has its own subsection; if you change a flag, hook, or default, update the subsection here as well as the README.
|
||
- Extension files themselves — the `// description:` comment header pi parses for the extension list view must stay accurate.
|
||
- `install.sh` — `--only` / `--skip` defaults, the list of artifacts it knows about, and the symlink targets. If you add a new extension file, the installer's auto-discovery picks it up; if you add a new artifact type that needs special handling, the script must be updated and the README block describing what `install.sh does` along with it.
|
||
|
||
Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md install.sh`. For an extension rename, also grep for the old name across the whole repo.
|