pi-extensions
Custom and modified extensions for the pi coding-agent.
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 (bring-up) and skillset (agent skills).
Install
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:
./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:
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
./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:
# 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) bashavailable 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 temporarySSH_ASKPASSscript (/tmp/pi-askpass-<pid>.sh,chmod 700) which is deleted immediately after the master is established.
How it works:
- On
session_start, runsssh -G <host>to read the effective config for that host - If
~/.ssh/configalready configuresControlMaster autooryesfor the host and itsControlPathdirectory 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) - Otherwise (no system master, or its
ControlPathis on a read-only mount — e.g.~/.ssh/cmwhen~/.sshis 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 ControlPathoverrides the user's unwritable path. - The remote
pwdis resolved with a direct connection (-o ControlPath=none -o ControlMaster=no) so a read-only systemControlPathcan't make the initial probe fail - All tool calls multiplex over the socket with
-o ControlMaster=no -o ControlPath=<socket>— near-zero per-call overhead - The system prompt is patched to tell the LLM it's operating on
<remoteCwd> (via SSH ControlMaster: <remote>) - 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
sudocommand chmod/chown 777dd 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:
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
↑/↓— navigatespace— stage a toggle (visual●/○flip; not yet applied)enter— commit all staged changes and reload piesc— 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 --uninstallcleans up both.tsand.ts.offsymlinks pointing into this repo, so a disabled extension won't be left behind.- Re-running
./install.shrespects a prior/extdisable: if<name>.ts.offalready exists, the installer leaves it alone instead of silently re-enabling. ssh-controlmastercannot be disabled via/extwhile pi was launched with--ssh— disabling mid-session would silently revert tool calls to the local filesystem. Exit pi and relaunch without--sshinstead.
Adding a new extension
- Drop a
.tsfile intoextensions/ - Re-run
./install.sh— it picks up the new file and symlinks it - In a running pi session,
/reloadis enough; no restart needed - (or, with
ext-toggleinstalled:/extto disable noisy ones at runtime)
task.ts
Registers the pi-task 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_allowednot an exact subset ofroots, and a writable root nested inside a watched-only root (the parent's porcelain would change every time); - serialises sibling
taskcalls 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.jsonasdetails. 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/--resumebrings the todos back with the conversation./forkforks 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:
{
// … 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 eitherapplication/jsonortext/event-stream. OptionalMcp-Session-Idround-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 asmempalace.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.tsbut does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't listmempalacein themcpblock 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 registeredfailed: <message>— start handshake threwdisabled in settings—enabled: falseinvalid: <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 — no compilation step. See the pi extensions docs and the built-in examples for the API surface.
aws-sso.ts
Setup guide, modes per device type, log reference and troubleshooting: 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 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
# 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— pi bring-up: settings template, keybindings, shell env loadermempalace-toolkit— persistent memory layer for pi via MemPalace MCPskillset— agent skills for pi, opencode, and Claude
License
MIT — see LICENSE.