From 6307072b21727e353d35fab71d58d0372853b76d Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Tue, 5 May 2026 22:45:08 +0200 Subject: [PATCH] init: pi-extensions with ssh-controlmaster --- LICENSE | 21 ++ README.md | 124 +++++++++++ extensions/ssh-controlmaster.ts | 379 ++++++++++++++++++++++++++++++++ install.sh | 153 +++++++++++++ package.json | 10 + 5 files changed, 687 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 extensions/ssh-controlmaster.ts create mode 100755 install.sh create mode 100644 package.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bdce26 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a8afde --- /dev/null +++ b/README.md @@ -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= ` +2. All tool calls use `-o ControlMaster=no -o ControlPath=` to multiplex over that socket — near-zero per-call overhead +3. The system prompt is patched to tell the LLM it's operating on ` (via SSH ControlMaster: )` +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). diff --git a/extensions/ssh-controlmaster.ts b/extensions/ssh-controlmaster.ts new file mode 100644 index 0000000..0769e5d --- /dev/null +++ b/extensions/ssh-controlmaster.ts @@ -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 { + 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 { + 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 { + 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((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 }; + } + }); +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..51f3898 --- /dev/null +++ b/install.sh @@ -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 <&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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c995631 --- /dev/null +++ b/package.json @@ -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"] + } +}