Files
pi-extensions/README.md
T
joakimp 25c1265681 task tool + fork-gate: put the "pi-task, not fork" rule where the decision is made
The rule was correct and written down twice (global AGENTS.md, this skill)
and was still violated by agents that had just read it: on 2026-09-17 all
five fork briefs in one session carried "do not", one returned confident
verbatim quotes that did not exist, and four had disjoint write boundaries
fork cannot enforce (re-run as pi-task, they passed). Three mechanisms,
none of them wording:

  1. fork is a TOOL — its self-recommending description ("implementation,
     testing, review…") is in the model's face every turn; pi-task was a CLI
     to be remembered and reached through bash with a hand-written JSON.
  2. The skill is gone after the first compaction; the tool list never is.
     The asymmetry widens in exactly the long sessions where fork is worst.
  3. Friction: one string vs a spec file + bash + reading result.json.

extensions/task.ts registers pi-task as the `task` tool. Flat parameters
build the spec; the decision rule sits in the description and in
promptGuidelines (appended to the system prompt, so compaction cannot remove
it). Before spending a model run it rejects the two spec errors that make a
boundary violation certain — write_allowed not an exact subset of roots, and
a writable root nested in a watched-only root (the parent's porcelain would
change every time; pi-task keys deltas by root string) — and it serialises
sibling tasks whose roots overlap (parallel siblings saw each other's writes
as violations, 2026-09-17). Returns the CLI's own parent-facing report; a
FAIL verdict is a result, only a CLI refusal is an error.

extensions/fork-gate.ts is a tool_call hook that BLOCKS a fork whose brief
contains a prohibition, a write boundary, or a clause-initial file-changing
imperative, and returns as the reason the exact task(...) to make instead.
Wording, not intent — the message says so and how to rephrase a genuinely
read-only brief. PI_FORK_GATE=off logs instead; /ext disables.

Evidence: test/fork-gate.test.mjs is two-sided (15 must-block incl. the real
shapes, 10 must-pass incl. "Write a summary…", "Report which files were
modified…", "Give me an update…"); the classifier redirects 5/5 of the real
briefs from the motivating session. test/task.test.mjs pins root overlap and
the pre-launch validation. Live in `pi -p`: the fork was intercepted before
any child spawned (no /tmp/pi-fork-* dir) and the model received the
redirect; task returned PASS with an evidence pointer and audit dir, a
budget-overrun returned a FAIL result (isError=false), and a nested-root spec
was rejected with no audit dir created.

skill/SKILL.md: Part 1 now opens with "decide the rung before the brief"
(the table, the three-question pre-flight, the roots contract, overlap,
what isolation does not fix); the ladder section and quick reference no
longer say pi-task "will never appear in your tool list". package.json gains
"type": "module" and a test script.
2026-09-19 16:41:23 +02:00

384 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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.
### `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-loader.ts`
Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing all of their tools to pi as native tools — namespaced as `<server-name>_<tool-name>` to avoid collisions, with non-`[A-Za-z0-9_]` characters replaced by `_` so the names pass the strictest provider tool-name regex (e.g. AWS Bedrock).
**Settings.json shape:**
```jsonc
{
// … existing pi settings …
"mcp": {
"searxng": {
"type": "local",
"command": ["uvx", "mcp-searxng"],
"env": { "SEARXNG_URL": "https://searxng.your-host.lan" }
},
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"enabled": false,
"env": { "GITEA_ACCESS_TOKEN": "...", "GITEA_HOST": "https://gitea.example.com" }
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp"
}
}
}
```
**Per-server keys:**
| Key | Description |
|---|---|
| `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. |
| `command` | Argv array. First element is the executable, rest are args. Local servers only. |
| `url` | Remote MCP endpoint URL. Remote servers only. |
| `headers` | Optional object of HTTP headers (e.g. `Authorization`, `X-API-Key`) sent with every request. Remote servers only. |
| `enabled` | Default `true`. Set `false` to disable a server without removing the entry. |
| `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. |
**Transports:**
- `local` — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, mcp-server-time…).
- `remote` — streamable-HTTP per MCP spec 2025-03-26: POST JSON-RPC, server replies either `application/json` or `text/event-stream`. Optional `Mcp-Session-Id` round-trip if the server issues one. No GET subscription stream (server-initiated notifications are not consumed).
**Limitations:**
- **No stdio reconnect** if a subprocess dies mid-session — those tools become unavailable until `/reload` (same as `mempalace.ts`).
- **Remote sessions self-heal on 404.** If a streamable-HTTP server forgets our session id (e.g. server restart), the client transparently re-initializes and retries the request once.
- **No OAuth flow.** Remote servers requiring OAuth must be accessed with a pre-issued bearer token via `headers`.
- **Coexists with `mempalace.ts`** but does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't list `mempalace` in the `mcp` block too — you'd get duplicate tool registrations.
**Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs.
**Slash command:** `/mcp` opens a multi-toggle overlay listing every server in the `mcp` block with its runtime status:
- `running · N tools` — connected, tools registered
- `failed: <message>` — start handshake threw
- `disabled in settings` — `enabled: false`
- `invalid: <message>` — malformed config (read-only row)
UX matches `/ext`: **space** stages a toggle, **enter** writes back to `settings.json` and reloads pi, **esc** cancels. Toggling re-enables a previously-disabled server by removing the explicit `enabled` key (the default is `true`).
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/earendil-works/pi/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/earendil-works/pi/tree/main/examples/extensions) for the API surface.
---
## 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).