init: pi-extensions with ssh-controlmaster

This commit is contained in:
Joakim Persson
2026-05-05 22:45:08 +02:00
commit 6307072b21
5 changed files with 687 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 joakimp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+124
View File
@@ -0,0 +1,124 @@
# pi-extensions
Custom and modified extensions for the [pi coding-agent](https://github.com/mariozechner/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`](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.
### 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
# Remote home dir resolved automatically via pwd
pi --ssh user@192.168.1.10
# Explicit remote path (skips the initial pwd call)
pi --ssh root@proxmox-node:/etc/pve
# 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 (no password prompts — ControlMaster won't work with interactive auth)
- `bash` available on the remote
**How it works:**
1. On `session_start`, establishes a ControlMaster: `ssh -fN -o ControlMaster=yes -o ControlPersist=yes -o ControlPath=<socket> <remote>`
2. All tool calls use `-o ControlMaster=no -o ControlPath=<socket>` to multiplex over that socket — near-zero per-call overhead
3. The system prompt is patched to tell the LLM it's operating on `<remoteCwd> (via SSH ControlMaster: <remote>)`
4. User `!` shell commands are also routed over SSH
5. On `session_shutdown`, sends `ssh -O exit` to cleanly tear down the master
**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.
---
## 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
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/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/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).
+379
View File
@@ -0,0 +1,379 @@
/**
* SSH ControlMaster Remote Execution
*
* Like the ssh.ts example but uses a persistent ControlMaster socket so all
* tool calls (read, write, edit, bash) multiplex over a single SSH connection
* instead of opening a new one each time. Much faster on slow links or when
* pi makes many small file reads in sequence.
*
* Usage:
* pi -e ~/.pi/agent/extensions/ssh-controlmaster.ts --ssh user@host
* pi -e ~/.pi/agent/extensions/ssh-controlmaster.ts --ssh user@host:/remote/path
*
* Or, once installed globally, just:
* pi --ssh user@host
*
* Requirements:
* - SSH key-based auth (no password prompts)
* - bash on the remote machine
*/
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
type BashOperations,
createBashTool,
createEditTool,
createReadTool,
createWriteTool,
type EditOperations,
type ReadOperations,
type WriteOperations,
} from "@mariozechner/pi-coding-agent";
// ── ControlMaster helpers ────────────────────────────────────────────────────
function controlSocketPath(): string {
// Keep path short — macOS has a ~104-char Unix socket path limit
return join(tmpdir(), `pi-cm-${process.pid}.sock`);
}
function startControlMaster(remote: string, socketPath: string): Promise<void> {
return new Promise((resolve, reject) => {
// -fN: fork to background after auth, don't run a remote command
// ControlPersist=yes: keep the master alive in the background indefinitely
const child = spawn(
"ssh",
[
"-fN",
"-o", "ControlMaster=yes",
"-o", `ControlPath=${socketPath}`,
"-o", "ControlPersist=yes",
remote,
],
{ stdio: "ignore" },
);
child.on("error", reject);
child.on("close", (code) => {
// -f forks to background; parent exits 0 once master is ready
if (code === 0) resolve();
else reject(new Error(`ControlMaster exited with code ${code}`));
});
});
}
function stopControlMaster(remote: string, socketPath: string): Promise<void> {
return new Promise((resolve) => {
// -O exit sends the exit signal to the master process via the socket
const child = spawn(
"ssh",
["-O", "exit", "-o", `ControlPath=${socketPath}`, remote],
{ stdio: "ignore" },
);
child.on("close", () => resolve()); // best-effort; ignore errors on cleanup
});
}
// ── SSH exec (multiplexed over ControlMaster socket) ────────────────────────
function sshExec(remote: string, socketPath: string, command: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn(
"ssh",
[
"-o", "ControlMaster=no", // use existing master, don't create a new one
"-o", `ControlPath=${socketPath}`,
remote,
command,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
const chunks: Buffer[] = [];
const errChunks: Buffer[] = [];
child.stdout.on("data", (d: Buffer) => chunks.push(d));
child.stderr.on("data", (d: Buffer) => errChunks.push(d));
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`));
} else {
resolve(Buffer.concat(chunks));
}
});
});
}
// ── Remote tool operations ───────────────────────────────────────────────────
function createRemoteReadOps(
remote: string,
socketPath: string,
remoteCwd: string,
localCwd: string,
): ReadOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
readFile: (p) => sshExec(remote, socketPath, `cat ${JSON.stringify(toRemote(p))}`),
access: (p) =>
sshExec(remote, socketPath, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
detectImageMimeType: async (p) => {
try {
const r = await sshExec(
remote,
socketPath,
`file --mime-type -b ${JSON.stringify(toRemote(p))}`,
);
const m = r.toString().trim();
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m)
? (m as "image/jpeg" | "image/png" | "image/gif" | "image/webp")
: null;
} catch {
return null;
}
},
};
}
function createRemoteWriteOps(
remote: string,
socketPath: string,
remoteCwd: string,
localCwd: string,
): WriteOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
writeFile: async (p, content) => {
const b64 = Buffer.from(content).toString("base64");
await sshExec(
remote,
socketPath,
`echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`,
);
},
mkdir: (dir) =>
sshExec(remote, socketPath, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
};
}
function createRemoteEditOps(
remote: string,
socketPath: string,
remoteCwd: string,
localCwd: string,
): EditOperations {
const r = createRemoteReadOps(remote, socketPath, remoteCwd, localCwd);
const w = createRemoteWriteOps(remote, socketPath, remoteCwd, localCwd);
return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
}
function createRemoteBashOps(
remote: string,
socketPath: string,
remoteCwd: string,
localCwd: string,
): BashOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
exec: (command, cwd, { onData, signal, timeout }) =>
new Promise((resolve, reject) => {
const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
const child = spawn(
"ssh",
[
"-o", "ControlMaster=no",
"-o", `ControlPath=${socketPath}`,
remote,
cmd,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let timedOut = false;
const timer = timeout
? setTimeout(() => {
timedOut = true;
child.kill();
}, timeout * 1000)
: undefined;
child.stdout.on("data", onData);
child.stderr.on("data", onData);
child.on("error", (e: Error) => {
if (timer) clearTimeout(timer);
reject(e);
});
const onAbort = () => child.kill();
signal?.addEventListener("abort", onAbort, { once: true });
child.on("close", (code: number) => {
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
if (signal?.aborted) reject(new Error("aborted"));
else if (timedOut) reject(new Error(`timeout:${timeout}`));
else resolve({ exitCode: code });
});
}),
};
}
// ── Extension ────────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
pi.registerFlag("ssh", {
description: "SSH remote: user@host or user@host:/remote/path",
type: "string",
});
const localCwd = process.cwd();
const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(localCwd);
const localEdit = createEditTool(localCwd);
const localBash = createBashTool(localCwd);
// Resolved lazily in session_start once CLI flags are available
let resolvedSsh: {
remote: string;
remoteCwd: string;
socketPath: string;
} | null = null;
const getSsh = () => resolvedSsh;
// ── Tool overrides ───────────────────────────────────────────────────────
pi.registerTool({
...localRead,
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSsh();
if (ssh) {
const tool = createReadTool(localCwd, {
operations: createRemoteReadOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localRead.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localWrite,
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSsh();
if (ssh) {
const tool = createWriteTool(localCwd, {
operations: createRemoteWriteOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localWrite.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localEdit,
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSsh();
if (ssh) {
const tool = createEditTool(localCwd, {
operations: createRemoteEditOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localEdit.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localBash,
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSsh();
if (ssh) {
const tool = createBashTool(localCwd, {
operations: createRemoteBashOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localBash.execute(id, params, signal, onUpdate);
},
});
// ── Session lifecycle ────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
const arg = pi.getFlag("ssh") as string | undefined;
if (!arg) return;
let remote: string;
let remoteCwd: string;
if (arg.includes(":")) {
[remote, remoteCwd] = arg.split(":");
} else {
remote = arg;
// Resolve remote home dir via a plain one-shot SSH call before the
// ControlMaster is up — this is the only un-multiplexed connection.
const result = await new Promise<string>((resolve, reject) => {
const child = spawn("ssh", [remote, "pwd"], {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
child.stdout.on("data", (d: Buffer) => chunks.push(d));
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(Buffer.concat(chunks).toString().trim());
else reject(new Error(`Could not resolve remote pwd (exit ${code})`));
});
});
remoteCwd = result;
}
const socketPath = controlSocketPath();
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${remote} ⟳ connecting…`));
try {
await startControlMaster(remote, socketPath);
} catch (err) {
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ failed`));
ctx.ui.notify(`SSH ControlMaster failed: ${err}`, "error");
return;
}
resolvedSsh = { remote, remoteCwd, socketPath };
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH ⚡ ${remote}:${remoteCwd}`));
ctx.ui.notify(`SSH ControlMaster ready — ${remote}:${remoteCwd}`, "success");
});
pi.on("session_shutdown", async (_event, _ctx) => {
const ssh = getSsh();
if (ssh) {
await stopControlMaster(ssh.remote, ssh.socketPath);
resolvedSsh = null;
}
});
// ── User ! commands via SSH ──────────────────────────────────────────────
pi.on("user_bash", (_event) => {
const ssh = getSsh();
if (!ssh) return;
return {
operations: createRemoteBashOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
};
});
// ── Tell the LLM where it's operating ───────────────────────────────────
pi.on("before_agent_start", async (event) => {
const ssh = getSsh();
if (ssh) {
const modified = event.systemPrompt.replace(
`Current working directory: ${localCwd}`,
`Current working directory: ${ssh.remoteCwd} (via SSH ControlMaster: ${ssh.remote})`,
);
return { systemPrompt: modified };
}
});
}
Executable
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# install.sh — install pi-extensions
#
# Symlinks each extension in extensions/ into ~/.pi/agent/extensions/ so pi
# loads them automatically on every session. Idempotent and non-destructive.
#
# Usage:
# ./install.sh # install (interactive confirm)
# ./install.sh --yes # install without prompt
# ./install.sh --uninstall # remove symlinks that point into this repo
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
EXTENSIONS_SRC="${SCRIPT_DIR}/extensions"
EXTENSIONS_DEST="${HOME}/.pi/agent/extensions"
PI_AGENT_DIR="${HOME}/.pi/agent"
# ── helpers ──────────────────────────────────────────
ok() { printf ' \e[32m✓\e[0m %s\n' "$*"; }
note() { printf '==> %s\n' "$*"; }
warn() { printf ' \e[33m!\e[0m %s\n' "$*" >&2; }
err() { printf ' \e[31m✗\e[0m %s\n' "$*" >&2; }
confirm() {
[[ "$ASSUME_YES" == "yes" ]] && return 0
read -r -p "Proceed? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]]
}
link_into_repo() {
local target
[[ -L "$1" ]] || return 1
target=$(readlink -f "$1" 2>/dev/null || true)
[[ "$target" == "$SCRIPT_DIR"/* ]]
}
require_pi_installed() {
if [[ ! -d "$PI_AGENT_DIR" ]]; then
err "pi not detected at $PI_AGENT_DIR"
printf ' Install pi first: https://github.com/mariozechner/pi-coding-agent\n'
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
exit 4
fi
mkdir -p "$EXTENSIONS_DEST"
ok "pi detected at $PI_AGENT_DIR"
}
# ── args ─────────────────────────────────────────────
ACTION="install"
ASSUME_YES="no"
while [[ $# -gt 0 ]]; do
case "$1" in
--uninstall) ACTION="uninstall"; shift ;;
-y|--yes) ASSUME_YES="yes"; shift ;;
-h|--help)
cat <<EOF
install.sh — install pi-extensions
Usage:
./install.sh install (interactive confirm)
./install.sh --yes install without prompt
./install.sh --uninstall remove symlinks that point into this repo
Each .ts file in extensions/ is symlinked into ~/.pi/agent/extensions/.
Existing files that are already symlinked into this repo are left alone.
Existing real files or foreign symlinks are backed up with a timestamp.
EOF
exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# ── install ──────────────────────────────────────────
do_install() {
echo
echo "pi-extensions installer"
echo "Repository: $SCRIPT_DIR"
echo
require_pi_installed
local extensions=("${EXTENSIONS_SRC}"/*.ts)
if [[ ! -e "${extensions[0]}" ]]; then
warn "No .ts files found in ${EXTENSIONS_SRC}/ — nothing to install."
exit 0
fi
echo "==> Extensions to symlink into ${EXTENSIONS_DEST}/:"
for src in "${extensions[@]}"; do
printf ' %s\n' "$(basename "$src")"
done
echo
confirm || { echo "Aborted."; exit 0; }
echo
for src in "${extensions[@]}"; do
local name
name="$(basename "$src")"
local dest="${EXTENSIONS_DEST}/${name}"
note "Linking ${name}"
if [[ -e "$dest" || -L "$dest" ]]; then
if link_into_repo "$dest"; then
ok "${name} already linked"
continue
fi
local backup="${dest}.bak.$(date +%Y%m%d-%H%M%S)"
mv "$dest" "$backup"
warn "Existing ${dest} backed up to ${backup}"
fi
ln -s "$src" "$dest"
ok "Linked ${name}${src}"
done
echo
ok "Done. Reload pi with /reload or restart to pick up new extensions."
}
# ── uninstall ────────────────────────────────────────
do_uninstall() {
echo
echo "pi-extensions uninstaller"
echo "Repository: $SCRIPT_DIR"
echo
confirm || { echo "Aborted."; exit 0; }
echo
local removed=0
for dest in "${EXTENSIONS_DEST}"/*.ts; do
[[ -e "$dest" || -L "$dest" ]] || continue
if link_into_repo "$dest"; then
rm "$dest"
ok "Removed $(basename "$dest")"
(( removed++ )) || true
fi
done
if [[ $removed -eq 0 ]]; then
ok "No symlinks pointing into this repo found — nothing removed."
else
echo
ok "Done. Removed ${removed} symlink(s)."
fi
}
case "$ACTION" in
install) do_install ;;
uninstall) do_uninstall ;;
esac
+10
View File
@@ -0,0 +1,10 @@
{
"name": "pi-extensions",
"version": "0.1.0",
"description": "Custom and modified pi coding-agent extensions",
"keywords": ["pi-package"],
"license": "MIT",
"pi": {
"extensions": ["./extensions"]
}
}