b3dc979aba
Measured 2026-10-12 on emb-7kj4vr4g. A new session ran `env | grep …` and tried to redact the output itself with a sed. The sed only matched when TOKEN came after the `=`, so MEMPALACE_REMOTE_TOKEN=<value> went through. That raw value was found in 12 local session files back to 2026-08-16, and each of those sessions had sent it to its model provider. The palace stayed clean only because mempalace-toolkit's feeder scrubs transcripts before staging. Nothing guarded the step from tool output to model context. The new extension ports tier 1 of mempalace_redact.py exactly: the exact values of env vars whose name says secret are replaced with <redacted:NAME>. Matching a known value cannot hit anything that is not that secret. - tool_result: content text, structuredContent (always returned when content changes, so it is not dropped) and details. - context: the same pass over every request, for user `!` output, pasted text and resumed sessions that already hold a raw value. - tool_call: block a write/edit whose new text contains a live secret's placeholder, i.e. writing a redacted .env back. The reason says to use bash and let the shell expand "$NAME". PI_SECRET_GUARD=off makes it inert. Tests (16, two-sided): selection, ordering, redaction, deep walk with identity, every handler, the off switch. Two parity tests run mempalace_redact.py on the same synthetic env and compare the selected names and the output text. Thirteen mutations, one per rule, are each caught. Two mutations initially survived and exposed fixture gaps, which are now fixed. Full suite: 45/45. End-to-end in real pi (erdc/deepseek-v4.1-flash). The secret was synthetic, and a test-only probe 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 and 0 stored, placeholder in both; - resumed control session: 1 sent without the guard, 0 with it; - interactive `!echo $SECRET`: 0 sent, plus a TUI warning; - a write of the placeholder: blocked, file sha unchanged. Not covered, as documented: live progress (tool_execution_update), the stored entry of a user `!` command, secrets not in env (no shape rules), and pi-task children, which run --no-extensions.
507 lines
28 KiB
Markdown
507 lines
28 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
|
|
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. 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.
|
|
- **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`
|
|
|
|
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 `/reload`s.
|
|
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:
|
|
|
|
```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.
|