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.
467 lines
28 KiB
Markdown
467 lines
28 KiB
Markdown
# pi-extensions
|
||
|
||
Custom and modified extensions for the [pi coding-agent](https://github.com/earendil-works/pi).
|
||
|
||
This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically.
|
||
|
||
Part of the same family as [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) (bring-up) and [`skillset`](https://gitea.jordbo.se/joakimp/skillset) (agent skills).
|
||
|
||
---
|
||
|
||
## Install
|
||
|
||
```bash
|
||
git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-extensions.git ~/src/src_local/pi-extensions
|
||
cd ~/src/src_local/pi-extensions
|
||
chmod +x install.sh
|
||
./install.sh
|
||
```
|
||
|
||
Each `.ts` file in `extensions/` is symlinked into `~/.pi/agent/extensions/`. Existing real files are backed up with a timestamp. Re-runs are idempotent. A link into this repo whose file is gone (an extension that was retired, like `mcp-loader.ts`) is removed.
|
||
|
||
## Bundled skill
|
||
|
||
`skill/SKILL.md` is the agent skill that documents *how to use* these extensions
|
||
(fork tier selection, recall discipline, task design, remote-pi mechanics). It
|
||
lives here, co-located with the code it describes, so it travels with the
|
||
package and can be vendored by downstream consumers (e.g. `pi-devbox` bakes it
|
||
as a fallback skill so containers have it even without the `skillset` repo
|
||
mounted). `skill/evaluate-extension-usage.py` is referenced by the skill and
|
||
must stay alongside it. `install.sh` does not deploy the skill — skill
|
||
deployment remains the `skillset` repo's job on a normal workstation.
|
||
|
||
**Editing `skill/` obliges you to refresh the mirror.** `skillset` keeps a copy at
|
||
`skills/pi-extensions/`, and that copy — not this one — is what most consumers
|
||
read: every Mac's host-side `~/.agents/skills/pi-extensions` symlinks into it, and
|
||
a workstation with only `skillset` cloned has no other copy. The refresh has been
|
||
forgotten on two consecutive edits (the mirror drifted 4,890 B behind, then
|
||
9,579 B), so `hooks/pre-push` now warns at push time when a pushed `skill/` change
|
||
is not yet mirrored, printing the exact `cp`. It **warns rather than blocks** —
|
||
the refresh is a commit in another repo and chronologically comes after this push.
|
||
Enforcement lives downstream: `skillset`'s own pre-commit gate refuses a commit
|
||
that leaves the mirror stale. `./install.sh` activates the hook
|
||
(`core.hooksPath=hooks`, per-clone config that cannot be tracked); it is silent
|
||
when no `skillset` clone is on disk, since a reminder you cannot act on is noise.
|
||
|
||
**Install a subset:**
|
||
|
||
```bash
|
||
./install.sh --only ssh-controlmaster # just this one
|
||
./install.sh --only "ssh-controlmaster,other" # explicit list
|
||
./install.sh --skip "git-checkpoint" # all except these
|
||
```
|
||
|
||
`--only` and `--skip` accept comma-separated names without the `.ts` suffix. `--only` takes precedence if both are given.
|
||
|
||
### Alternative: pi install (local path)
|
||
|
||
Because `package.json` declares a `pi` manifest, you can also register this repo as a pi package:
|
||
|
||
```bash
|
||
pi install ~/src/src_local/pi-extensions
|
||
```
|
||
|
||
This makes pi manage the extension loading directly. The `install.sh` approach (symlinks) and `pi install` are mutually exclusive for the same extension — pick one per machine.
|
||
|
||
### Uninstall
|
||
|
||
```bash
|
||
./install.sh --uninstall
|
||
```
|
||
|
||
Removes symlinks that point into this repo. Your own files in `~/.pi/agent/extensions/` are never touched.
|
||
|
||
---
|
||
|
||
## Extensions
|
||
|
||
### `ssh-controlmaster.ts`
|
||
|
||
Transparent SSH remote execution via a persistent ControlMaster socket.
|
||
|
||
When launched with `--ssh user@host`, all of pi's native file and shell tools
|
||
(`read`, `write`, `edit`, `bash`) are transparently redirected to execute on
|
||
the remote machine. One SSH connection is established at session start; all
|
||
subsequent tool calls multiplex over it via a Unix socket. Much faster than
|
||
the plain `ssh.ts` example which opens a new connection per tool call.
|
||
|
||
**Use cases:**
|
||
- Diagnose and fix issues on a remote server without installing pi there
|
||
- Work on Proxmox hosts, LXC containers, or ephemeral VMs
|
||
- Any machine you have SSH key access to but don't own
|
||
|
||
**Usage:**
|
||
|
||
```bash
|
||
# Key-based auth (normal)
|
||
pi --ssh user@192.168.1.10
|
||
|
||
# Explicit remote path (skips the initial pwd call)
|
||
pi --ssh root@proxmox-node:/etc/pve
|
||
|
||
# Password auth — prompts before connecting
|
||
pi --ssh user@host --ssh-ask-pass
|
||
|
||
# Try without modifying your global install
|
||
pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@host
|
||
```
|
||
|
||
**Requirements:**
|
||
- SSH key-based auth (preferred), or password auth via `--ssh-ask-pass` (see below)
|
||
- `bash` available on the remote
|
||
|
||
> **Note on `--ssh-ask-pass`:** The password is prompted via pi's input dialog
|
||
> before the SSH connection is opened. Input is **not masked** — the password
|
||
> is visible while typing. It is passed to SSH via a temporary `SSH_ASKPASS`
|
||
> script (`/tmp/pi-askpass-<pid>.sh`, `chmod 700`) which is deleted
|
||
> immediately after the master is established.
|
||
|
||
**How it works:**
|
||
|
||
1. On `session_start`, runs `ssh -G <host>` to read the effective config for that host
|
||
2. If `~/.ssh/config` already configures `ControlMaster auto` or `yes` for the host **and its `ControlPath` directory is writable**, the existing system socket is reused — no second connection is opened and pi does **not** tear down the master on exit (it was the system's to manage)
|
||
3. Otherwise (no system master, **or** its `ControlPath` is on a read-only mount — e.g. `~/.ssh/cm` when `~/.ssh` is bind-mounted read-only) pi establishes its own master: `ssh -fN -o ControlMaster=yes -o ControlPersist=yes -o ControlPath=/tmp/pi-cm-<pid>.sock <remote>` and shuts it down cleanly on exit. The command-line `-o ControlPath` overrides the user's unwritable path.
|
||
4. The remote `pwd` is resolved with a direct connection (`-o ControlPath=none -o ControlMaster=no`) so a read-only system `ControlPath` can't make the initial probe fail
|
||
5. All tool calls multiplex over the socket with `-o ControlMaster=no -o ControlPath=<socket>` — near-zero per-call overhead
|
||
6. The system prompt is patched to tell the LLM it's operating on `<remoteCwd> (via SSH ControlMaster: <remote>)`
|
||
7. User `!` shell commands are also routed over SSH
|
||
|
||
**Hang-proofing:** every SSH invocation carries `ConnectTimeout=8` + `ServerAlive*` keepalives, and the startup probe / master start are additionally bounded by a 15 s wall-clock timeout. Key-auth calls also set `BatchMode=yes` so ssh can never wait silently on a `/dev/tty` password/passphrase or host-key prompt behind pi's TUI (`--ssh-ask-pass` omits `BatchMode` so the `SSH_ASKPASS` path still works). If the host can't be reached — e.g. a LAN target with **no route from inside a container** — the probe fails fast with an `✗ unreachable` status and an error toast instead of hanging startup and silently swallowing your prompts.
|
||
|
||
**LAN reachability from inside a container (`-F` config detection):** the extension threads an `-F <config>` into every ssh call when one is available, so `pi --ssh <peer>` can reach hosts that require a `ProxyJump` exactly like the `dssh` alias does. Resolution order: `PI_SSH_CONFIG=/path/to/config` if set (leading `~` expanded), else `~/.ssh-local/config` if it exists (the pi-devbox's `setup-lan-access.sh` regenerates it on every container start), else none. **No hostnames are baked into the image** — the LAN-jump list lives only in the host-owned, read-only-mounted `~/.config/devbox-shell/ssh-lan.conf`; on the host (native pi) `~/.ssh-local/config` doesn't exist so this is a no-op. The status/notify shows `[config: <path>]` when a non-default config is in use. To reach a new LAN peer from the container, add it to the host's `ssh-lan.conf` `ProxyJump host` line (it must already be a `Host` block in the host's `~/.ssh/config`).
|
||
|
||
The status bar shows `⚡ own master` or `⚡ system master` so you can see which path was taken.
|
||
|
||
**Status bar:** Shows `SSH ⚡ user@host:/path` when the master is ready, `⟳ connecting…` during setup, and an error state if the master fails to start.
|
||
|
||
**Path mapping:** Paths are rewritten by replacing the local `cwd` with the remote `cwd`. This means pi should be started from a directory that maps cleanly to a path on the remote. Use the `user@host:/explicit/path` form when the remote path differs significantly from your local working directory.
|
||
|
||
---
|
||
|
||
### `confirm-destructive.ts`
|
||
|
||
Confirmation gates for dangerous bash commands and destructive session actions. Always-on — no flag needed.
|
||
|
||
**Bash commands intercepted:**
|
||
- Recursive removes (`rm -rf`, `rm -r`, etc.)
|
||
- Any `sudo` command
|
||
- `chmod`/`chown 777`
|
||
- `dd if=` (disk operations)
|
||
- `mkfs` (format filesystem)
|
||
- `git push --force` / `git push -f`
|
||
- Writes to `/dev/*`
|
||
- `truncate --size 0`
|
||
|
||
In non-interactive mode (e.g. `pi -p`) dangerous commands are blocked outright rather than prompted.
|
||
|
||
**Session actions gated:**
|
||
- `/new` — confirms before clearing the session
|
||
- `/resume` — confirms before switching away if the current session has messages
|
||
- `/fork` — always confirms
|
||
|
||
---
|
||
|
||
### `git-checkpoint.ts`
|
||
|
||
Creates a git stash checkpoint at the start of each turn, keyed to the session entry ID. If you `/fork` from a past entry, you're offered the option to restore the code to that point.
|
||
|
||
Silently skips when the working directory isn't inside a git repo, or when there are no changes to stash. Status bar shows `⎇ N checkpoints` during active sessions.
|
||
|
||
**Notes:**
|
||
- Uses `git stash create` — non-destructive, doesn't touch your working tree
|
||
- Stash objects persist in the git repo even after pi exits, so you can apply them manually with `git stash apply <ref>` if needed
|
||
- Checkpoints are in-memory per session — the entry→ref mapping is lost on restart, but the underlying stash objects remain
|
||
|
||
---
|
||
|
||
### `notify.ts`
|
||
|
||
Sends a native terminal notification when the agent finishes and is waiting for input. Only fires when the agent ran for longer than the threshold (default 8 seconds) — quick responses are silently skipped.
|
||
|
||
**Terminal support:**
|
||
- Kitty (`KITTY_WINDOW_ID`) → OSC 99
|
||
- Windows Terminal / WSL (`WT_SESSION`) → Windows toast
|
||
- Everything else (iTerm2, WezTerm, Ghostty) → OSC 777
|
||
|
||
**Flag:**
|
||
|
||
```bash
|
||
pi --notify-min-secs 15 # only notify for tasks over 15 seconds
|
||
pi --notify-min-secs 0 # notify on every agent completion
|
||
```
|
||
|
||
### `ext-toggle.ts`
|
||
|
||
Registers `/ext` — a slash command that lists extensions in `~/.pi/agent/extensions/` and toggles individual ones on/off without leaving the TUI.
|
||
|
||
**How it works:** pi auto-discovers `*.ts` only. Toggling renames a file (or symlink) between `name.ts` and `name.ts.off`, so a disabled extension is invisible to the loader. After a toggle, the extension calls `ctx.reload()` so the change takes effect immediately — no restart needed.
|
||
|
||
**Usage:**
|
||
|
||
```
|
||
/ext # opens the multi-toggle overlay
|
||
```
|
||
|
||
- `↑` / `↓` — navigate
|
||
- `space` — stage a toggle (visual `●` / `○` flip; not yet applied)
|
||
- `enter` — commit all staged changes and reload pi
|
||
- `esc` — cancel, no changes
|
||
|
||
A footer line shows pending changes (e.g. `pending: notify→off, foo→on`) so you can see exactly what `enter` will apply. Guard rejections appear there too (`⊘ ssh-controlmaster: …`).
|
||
|
||
**Notes:**
|
||
- Subdirectory-style extensions (`name/index.ts`) are listed read-only — v1 doesn't toggle them. Move the directory aside manually if needed.
|
||
- `install.sh --uninstall` cleans up both `.ts` and `.ts.off` symlinks pointing into this repo, so a disabled extension won't be left behind.
|
||
- Re-running `./install.sh` respects a prior `/ext` disable: if `<name>.ts.off` already exists, the installer leaves it alone instead of silently re-enabling.
|
||
- `ssh-controlmaster` cannot be disabled via `/ext` while pi was launched with `--ssh` — disabling mid-session would silently revert tool calls to the local filesystem. Exit pi and relaunch without `--ssh` instead.
|
||
|
||
## Adding a new extension
|
||
|
||
1. Drop a `.ts` file into `extensions/`
|
||
2. Re-run `./install.sh` — it picks up the new file and symlinks it
|
||
3. In a running pi session, `/reload` is enough; no restart needed
|
||
4. (or, with `ext-toggle` installed: `/ext` to disable noisy ones at runtime)
|
||
|
||
### `task.ts`
|
||
|
||
Registers the [`pi-task`](https://gitea.jordbo.se/joakimp/pi-toolkit) runner as
|
||
the `task` tool: a delegated task runs in an **isolated** child agent that sees
|
||
only the spec (context ladder L0–L2), returns a machine-checked PASS/FAIL
|
||
envelope, has every `roots[]` entry diffed before and after (a change outside
|
||
`write_allowed` FAILS), and leaves an audit dir under `~/.pi/agent/pi-task/`.
|
||
Compare `fork`, whose child inherits the *entire* parent branch (L4) and returns
|
||
prose.
|
||
|
||
Why a tool and not just the CLI: `fork` is a tool with a self-recommending
|
||
description in the model's face every turn; the CLI had to be remembered, and the
|
||
prose rule that said "use pi-task for briefs with prohibitions" lost to the tool
|
||
list for months. The rule now lives in the tool description and in
|
||
`promptGuidelines`, which pi appends to the system prompt — the one place
|
||
compaction cannot remove it from.
|
||
|
||
Beyond wrapping the CLI the tool:
|
||
- rejects, **before** a model run, the two spec errors that make a boundary
|
||
violation certain — `write_allowed` not an exact subset of `roots`, and a
|
||
writable root nested inside a watched-only root (the parent's porcelain would
|
||
change every time);
|
||
- serialises sibling `task` calls whose roots overlap (parallel siblings see each
|
||
other's writes as violations — measured 2026-09-17);
|
||
- returns the CLI's parent-facing report as the tool result (verdict, problems,
|
||
deliverable, evidence pointers, audit dir) and the parsed `result.json` as
|
||
`details`. A FAIL verdict is a *result*; only the CLI refusing to run is an
|
||
error.
|
||
|
||
Finds the runner at `$PI_TASK_BIN`, `/opt/pi-toolkit/bin/pi-task`,
|
||
`~/src/pi-toolkit/bin/pi-task`, `~/src/src_local/pi-toolkit/bin/pi-task`,
|
||
`/workspace/pi-toolkit/bin/pi-task`, then `$PATH`. Effort tiers resolve through
|
||
`pi-fork.effortProfiles` in `settings.json`, same as `fork`.
|
||
|
||
### `fork-gate.ts`
|
||
|
||
A `tool_call` hook that **blocks** a `fork` whose brief contains a prohibition
|
||
(*do not / must not / never / only …*), a write boundary (*only touch, nothing
|
||
else, read-only, stay within …*) or a clause-initial file-changing imperative
|
||
(*Edit …, Commit …, Fix …, Implement …*), and returns — as the block reason the
|
||
model reads — the `task(...)` call to make instead, plus the CLI fallback.
|
||
|
||
Measured motivation: on 2026-09-17 all five fork briefs in one session carried
|
||
"do not"; one returned confident verbatim quotes that did not exist in the
|
||
source, and the four migration briefs had disjoint write boundaries that fork
|
||
cannot enforce (they were re-run as pi-task and passed). Running the shipped
|
||
classifier over those five real briefs redirects 5/5.
|
||
|
||
The gate matches **wording, not intent**, and says so: a genuinely read-only
|
||
exploration brief that trips it is rephrased without the prohibition; one that
|
||
cannot be rephrased needed `task`. Two-sided tests in
|
||
`test/fork-gate.test.mjs` pin both the must-block and must-pass sets (the latter
|
||
includes "Write a summary of…", "Report which files were modified…", "Give me an
|
||
update on…" — verbs a naive list would misfire on).
|
||
|
||
`PI_FORK_GATE=off` makes it log to stderr instead of blocking; `/ext` disables it.
|
||
|
||
### `secret-guard.ts`
|
||
|
||
Keeps the **values of this process's secret env vars** out of what the model
|
||
sees, what the session file stores, and what the palace feeder stages. A secret
|
||
var is one whose name says secret (`…TOKEN`, `…SECRET`, `…API_KEY`,
|
||
`…PASSWORD`, `_PAT`, …) and whose value looks like a credential rather than a
|
||
path, knob or placeholder. Each exact value is replaced with
|
||
`<redacted:NAME>`, the same placeholder mempalace-toolkit's palace redactor
|
||
writes.
|
||
|
||
Measured motivation, 2026-10-12: a new session ran an `env | grep …` with its own
|
||
`sed` redaction, and the `sed` missed `MEMPALACE_REMOTE_TOKEN=<value>`. That
|
||
value turned out to be in 12 local session files going back two months, and each
|
||
of those sessions had sent it to its model provider. The palace stayed clean only
|
||
because the feeder scrubs transcripts before upload; nothing guarded the step from
|
||
tool output to model context.
|
||
|
||
Three hooks:
|
||
|
||
| hook | what it does |
|
||
|---|---|
|
||
| `tool_result` | rewrites `content` (text parts), `structuredContent` and `details` of every tool result, MCP tools included. The model, the session file and therefore the palace feed only ever get the placeholder. |
|
||
| `context` | the same pass over everything sent to the model, per request. Covers user `!` command output, pasted text, and resumed sessions that already hold a raw value. Request-local: the session file is not changed. |
|
||
| `tool_call` | blocks a `write`/`edit` whose **new** text contains the placeholder of a live secret. That is what an agent writes back after reading a redacted `.env`, and it would replace the secret with the placeholder. The reason says to write the file with bash and let the shell expand `"$NAME"`. |
|
||
|
||
Commands keep working: `$NAME` still expands in bash; only the output that comes
|
||
back is rewritten. Matching a known value cannot hit anything that is not that
|
||
secret, so there is no shape or entropy heuristic to misfire. The cost is 36 ms
|
||
per request on a 53 MB session and under 1 ms on a normal one.
|
||
|
||
Not covered:
|
||
- secrets that are not in pi's environment (key files, another host's decrypted
|
||
`.env`);
|
||
- the live streaming view while a tool runs (only the final result is
|
||
rewritten);
|
||
- the stored entry of a user `!` command;
|
||
- pi-task children, which run `--no-extensions`.
|
||
|
||
Tests: `test/secret-guard.test.mjs` is two-sided. It also runs
|
||
`mempalace_redact.py` (from `/opt/mempalace-toolkit/bin`, or
|
||
`$MEMPALACE_REDACT_PY`) on the same synthetic environment, and fails if the two
|
||
pick different variables or produce different text. Every rule was
|
||
mutation-checked.
|
||
|
||
`PI_SECRET_GUARD=off` makes it inert; `/ext` disables it.
|
||
|
||
### `todo.ts`
|
||
|
||
Gives the agent a `todo` tool (actions: `list` / `add` / `toggle` / `clear`) so it can externalize a multi-step plan and tick items off as it works. Also registers `/todos` so you can inspect the current list at any time.
|
||
|
||
State lives in the session's tool result `details`, not an external file. So:
|
||
- `pi --continue` / `--resume` brings the todos back with the conversation.
|
||
- `/fork` forks the todo list along with the branch — each branch has its own state.
|
||
|
||
This is a verbatim copy of the upstream `examples/extensions/todo.ts` shipped with `pi-coding-agent`. Refresh from upstream when desired (see `AGENTS.md`).
|
||
|
||
### MCP servers — use pi's built-in support (`mcp-loader.ts` is retired)
|
||
|
||
`mcp-loader.ts` was removed on 2026-10-12. pi has shipped MCP support as a built-in extension since 0.99.0, and it covers everything the loader did and more:
|
||
|
||
| | `mcp-loader.ts` (retired) | pi built-in (`builtin:mcp`) |
|
||
|---|---|---|
|
||
| Config | `mcp` block in `settings.json` | `~/.pi/agent/mcp.json` (`mcpServers`, Claude Desktop shape); per-project `.pi/mcp.json` after trust |
|
||
| Manage | `/mcp` toggle overlay | `/mcp` manager (inspect tools, reconnect, sign in, enable/disable, exposure), `pi mcp add/remove/list/login/logout` from a shell |
|
||
| Auth | pre-issued bearer token in `headers` | headers with `${ENV}` / `!command` values, **and OAuth** (dynamic registration, CIMD, refresh) |
|
||
| Startup | awaited every server's `tools/list` while extensions loaded | connects in the background at session start; the first prompt waits (up to 10 s) only for servers with `direct` tools |
|
||
| Tools | every tool declared to the model as `<server>_<tool>` | `mcp__<server>__<tool>`; per-server/per-tool `exposure`: `codemode` (default), `deferred`, `direct`, `hidden` |
|
||
| Also | — | resources (`list_mcp_resources`, `read_mcp_resource`), per-request `timeout`, reconnect on next call, `mcp.log` |
|
||
|
||
Measured before retiring it (pi 1.1.0, pi-devbox v1.11.0, 7 interleaved runs each, time to pi's `(auto)` footer in a real pty): `-ne` 0.276 s, `-ne -e mcp-loader.ts` 0.283 s, `-ne -e builtin:mcp` 0.272 s. With no servers configured, the built-in adds nothing to a request: the same prompt cost 79 input + 7680 cached tokens with either loader, and with neither. A stdio server (`npx -y @modelcontextprotocol/server-everything`) connected in 3 s via `pi mcp list` and its `echo` tool was called end to end from `pi -p`.
|
||
|
||
**Why remove it rather than keep both:** an extension that registers `/mcp` *replaces* the built-in, so with the loader installed pi never read `mcp.json` and printed `Extension … registers command /mcp, so built-in extension mcp was not loaded` on every start.
|
||
|
||
**Moving an old `mcp` block** to `~/.pi/agent/mcp.json` (pi `docs/mcp.md`, "Migrate configuration from another client"):
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"searxng": { "command": "uvx", "args": ["mcp-searxng"], "env": { "SEARXNG_URL": "https://searxng.your-host.lan" }, "exposure": "direct" },
|
||
"context7": { "url": "https://mcp.context7.com/mcp", "headers": { "Authorization": "Bearer ${CONTEXT7_TOKEN}" } }
|
||
}
|
||
}
|
||
```
|
||
|
||
- `"type": "local"` + `"command": [exe, ...args]` becomes `"command": exe` + `"args": [...]`. `"type": "remote"` becomes a `url` entry.
|
||
- Set `"exposure": "direct"` to keep the loader's behaviour of declaring every tool to the model. The built-in default, `codemode`, makes tools reachable only from `codemode` scripts.
|
||
- Tool names change from `<server>_<tool>` to `mcp__<server>__<tool>`. Non-`[A-Za-z0-9_]` characters still become `_`, so the names stay valid for AWS Bedrock.
|
||
- `pi mcp list` validates the file and connects every enabled server.
|
||
|
||
`install.sh` now removes the stale `~/.pi/agent/extensions/mcp-loader.ts` link (and any other link into this repo whose file is gone). It warns once if `settings.json` still has an `mcp` block.
|
||
|
||
**MemPalace is unaffected.** `mempalace.ts` (mempalace-toolkit) runs its own MCP client and registers `mempalace_*` tools directly, so `exposure` never applies to them. Don't add MemPalace to `mcp.json`: you would get a second, `mcp__mempalace__*`, copy of every tool.
|
||
|
||
### `aws-sso.ts`
|
||
|
||
Setup guide, modes per device type, log reference and troubleshooting: [docs/aws-sso.md](docs/aws-sso.md).
|
||
|
||
Keeps a Bedrock **AWS SSO** session alive, like Claude Code's `awsAuthRefresh`:
|
||
when the IAM Identity Center session ends it runs `aws sso login` for you, before a
|
||
request fails. **You still approve the login** (or your browser does it silently
|
||
while it is signed in). Off until you enable it.
|
||
|
||
| Command | Effect |
|
||
|---|---|
|
||
| `/aws-sso on` / `off` | Enable/disable (persisted in `~/.pi/agent/aws-sso/state.json`) |
|
||
| `/aws-sso status` | Mode, push target, profile chain, session state, last check, last login |
|
||
| `/aws-sso now` | Log in now |
|
||
| `/aws-sso local` | **Default.** The machine running Docker has your browser: run `aws sso login` there over SSH (`~/.ssh-local/config`, host alias `host` or `$PI_AWS_SSO_SSH_HOST`). Silent while that browser is signed in. |
|
||
| `/aws-sso remote` | No browser you can see there (headless server, ssh + tmux, an always-on Mac used as a server): run `aws sso login --use-device-code --no-browser` in the container, show link + code in pi and push them; approve on any device. AWS always asks for a click in this flow. |
|
||
| `/aws-sso continue on\|off` | After a request failed on an expired session, send `continue` once the renewal succeeds (default on) |
|
||
| `/aws-sso push-test` | Send a test push |
|
||
|
||
Settings in pi-devbox `.env` (reaches the container via `env_file`; recreate the container after editing):
|
||
|
||
| Variable | Meaning |
|
||
|---|---|
|
||
| `PI_AWS_SSO_REFRESH=1` / `0` | Force the keeper on / off regardless of `/aws-sso on\|off` |
|
||
| `PI_AWS_SSO_MODE=local\|remote` | Mode when none was set by command (a command-set mode wins) |
|
||
| `PI_AWS_SSO_NTFY=https://ntfy.sh/<topic>` | Push target: an [ntfy](https://ntfy.sh) topic your phone's ntfy app subscribes to. Treat the topic name as a password (long and random), or use a self-hosted ntfy. |
|
||
| `PI_AWS_SSO_NTFY_TOKEN` | Access token for a protected ntfy topic (optional) |
|
||
| `PI_AWS_SSO_SSH_HOST` | SSH alias of the Docker host for `local` (default `host`) |
|
||
| `PI_AWS_SSO_LOCAL_CMD` | Command run on the host for `local`; `{session}` is replaced by the sso-session name. Default: `exec "${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'`. Verified on a macOS host; a Linux or Windows host may need its own command so the login can reach the desktop browser from an SSH session. |
|
||
|
||
**Pushes** (only when `PI_AWS_SSO_NTFY` is set): the approval link and code (`remote`,
|
||
tap to open); "login waiting" when a `local` login has not finished after 60 s; a
|
||
login that did not complete; and "renewed" after a login you were pushed about.
|
||
Status and logs show only the ntfy server and a fingerprint of the topic.
|
||
|
||
**How it detects the end.** Per AWS ("Understanding authentication sessions"), the
|
||
CLI's 1-hour token is refreshed "as long as the underlying user interactive session
|
||
remains valid", meaning the browser's AWS sign-in. That sign-in is admin-capped and
|
||
measured 8 h here (2026-10-10 from the portal cookie; 2026-10-12 from the token cap
|
||
below). In the session's last hour AWS still issues new 1-hour tokens but **caps them
|
||
at the session's end**; after the end it refuses. Inside the SDK's 5-minute pre-expiry
|
||
window both are *silent*: the SDK keeps returning a still-valid token. So the keeper
|
||
makes that refresh attempt itself as soon as the window opens, with pi's own
|
||
`@aws-sdk/token-providers` and the same cache file
|
||
(`~/.aws/sso/cache/<sha1(sso-session)>.json`), and reads the result from the file:
|
||
a new full token = alive; a new token issued for under 55 min = **capped**, and its
|
||
expiry is the session's end; nothing written = **refused**. On a cap the keeper logs
|
||
in 30 s after that end. On a refusal it logs in at once while the old token still
|
||
covers requests. Refusals also happened with time left (cause unknown): a probe at
|
||
00:13 local on 2026-10-12, 5 min before the end, and a failed request ~2 h before it;
|
||
both times a login worked at once. Nothing is renewed on a clock, and never
|
||
before a known end: a login while the browser sign-in is valid completes silently
|
||
("…can be used until your session expires") but inherits its end, so it gains nothing. Tokens are only ever logged as `sha256[:8]`
|
||
fingerprints (`~/.pi/agent/aws-sso/events.jsonl`); the token the SDK returns is
|
||
dropped unread.
|
||
|
||
**Triggers:** a 20 s timer that checks every 60 s inside the refresh window (every
|
||
10 min once the token has run out). At least 2 min pass between unprompted logins;
|
||
after an unanswered one the keeper retries after 30 min, 1 h, 2 h, then every 4 h, so
|
||
a login waiting for you does not pile up tabs or pushes overnight. `before_agent_start`
|
||
checks first if the token is in its window and logs in (and waits) if the session
|
||
is over. `message_end` is the fallback: if a request still fails on an expired
|
||
token, the keeper renews and sends `continue` (once per 10 min), also when you
|
||
approve hours later.
|
||
Only interactive sessions act; print/JSON children (`task`, `fork`) stay inert.
|
||
A lock file stops several pi processes from starting several logins.
|
||
|
||
## Deploying on a new machine
|
||
|
||
```bash
|
||
# 1. Prerequisites: pi installed, SSH key auth working
|
||
pi --help # creates ~/.pi/agent/ on first run
|
||
|
||
# 2. Clone and install
|
||
git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-extensions.git ~/src/src_local/pi-extensions
|
||
cd ~/src/src_local/pi-extensions && ./install.sh
|
||
|
||
# 3. Verify
|
||
ls -la ~/.pi/agent/extensions/ # should show symlinks into this repo
|
||
```
|
||
|
||
---
|
||
|
||
## Related repos
|
||
|
||
- [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) — pi bring-up: settings template, keybindings, shell env loader
|
||
- [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) — persistent memory layer for pi via MemPalace MCP
|
||
- [`skillset`](https://gitea.jordbo.se/joakimp/skillset) — agent skills for pi, opencode, and Claude
|
||
|
||
## License
|
||
|
||
MIT — see [`LICENSE`](LICENSE).
|