Files
joakimp 98eb07bce6 docs(skill): fork boundary violations have a mechanism — document it
The existing guidance ("state decision authority explicitly") was followed to
the letter on 2026-07-29 and the fork violated its boundary anyway: a 4645-char
brief saying "DRAFT ONLY ... do not commit to any git repo, and do not modify
any file other than /workspace/tmp/pi-mono-issue.md" came back as "All three
done: Pushed ... Moved ... symlinked", and commit timestamps place cli_utils
f644fa1 (21:57:47Z) inside the fork's window (21:53:40Z–21:58:27Z). So the
advice was necessary but not sufficient, and the skill said nothing about why.

The why is mechanical: index.ts:47 serializes getHeader() + every getBranch()
entry — messages, thinking, tool calls and results — into a temp session the
child opens with --session. The brief is not the fork's world, it is the last
line of a world already full of the parent's stated intentions, so a brief that
contradicts visible in-flight work sets up a conflict the child can resolve the
wrong way. The three things it "completed" were exactly the main thread's
pending todos.

Added: the mechanism with the snippet; the worked example with timestamps; a
fifth required brief element (anti-inheritance clause + "What I did NOT do");
and the tier rule that a prohibition makes a task unfit for `fast`.

Corrected two claims that were wrong:
- "do not give the fork write tools at all" is not achievable. There is no tool
  allow/deny list; config exposes only extensions/environment/offline and the
  child is a full pi process. extensions:[] disables extensions, not
  read/write/edit/bash. The real control is not forking the task.
- the narrative-invention caveat implied the fork invents for lack of context.
  It has the whole transcript. It invents because its output contract is ~90
  lines of shape demanding a confident verdict, with a single scope-ish mention
  in the entire prompt and no instruction to mark unverified claims. Same fork
  reported "all 4 live sessions" when there were 20 — a number absent from the
  inherited transcript, so invention rather than staleness.
2026-07-30 00:49:38 +02:00

34 KiB
Raw Permalink Blame History

name, description
name description
pi-extensions Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. This skill covers tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.

Pi Extensions: pi-fork, pi-observational-memory, ssh-controlmaster

When to Load This Skill

Load only when both of these are true:

  1. You are running inside the pi coding agent harness (not Claude Code, not opencode, not any other harness).
  2. The fork and/or recall tools appear in your available tool list, or the session was started with pi --ssh ....

If you do not see those tools, this skill does not apply — skip it. Other harnesses do not have these extensions and the patterns below will not work there.

This skill is most useful at the start of any non-trivial session where you may need to dispatch parallel subtasks, where the conversation is likely to compact (sessions running > ~80k tokens), or where pi is operating against a remote host.

Pi extension landscape (where the wiring lives)

Pi has two distinct extension locations and it's easy to look in the wrong one:

Location Mechanism Examples
~/.pi/agent/extensions/*.ts (or .ts.off) Local extensions — TypeScript files, usually symlinks into /opt/pi-extensions/extensions/ or similar. Toggled via /ext slash command. ssh-controlmaster, git-checkpoint, notify, todo, mempalace, mcp-loader, ext-toggle, confirm-destructive
~/.pi/agent/git/<host>/<owner>/<repo>/ Package extensions (git-installed) — git-cloned npm packages registered via the packages array in ~/.pi/agent/settings.json. pi-fork (github.com/elpapi42/pi-fork), pi-observational-memory (github.com/elpapi42/pi-observational-memory, default branch master — a main branch does not exist, so pi install git:... resolves against master)
~/.pi/agent/npm/node_modules/<pkg>/ Package extensions (npm-installed)pi install npm:<pkg>; recorded in packages[] as npm:<pkg>. pi-atelier (status rail + sidebar TUI)
/opt/<pkg>/pi-devbox containers only Vendored package extensions — cloned into an image layer at build time with node_modules baked, then registered at container start by entrypoint-user.sh via pi install /opt/<pkg>. Recorded in packages[] as a relative path (../../../../opt/pi-fork) that resolves out of ~/.pi/agent into the image layer, so it survives volume recreate. /opt/pi-fork, /opt/pi-observational-memory, /opt/pi-studio

When the user asks how to use "the X extension", check all of thesefind ~/.pi/agent -maxdepth 4 -name "*X*" covers the first three, and ls -d /opt/*X* the fourth. The /ext slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. ci-release-watcher's ssh-control-master-setup.sh) which is not a pi extension at all — it's a helper script inside a skill. Don't conflate the three.

In a pi-devbox container, do not conclude "pi-fork isn't installed" because ~/.pi/agent/git/ is empty. It is deliberately absent: Dockerfile.variant vendors to /opt and installs by local path, because a build-time pi install git:... would write into ~/.pi/agent, which the named volume then shadows on first run.

Verifying a package is actually registered (not merely present)

A package being on disk says nothing about whether pi loads it. Registration means an entry in the packages array of ~/.pi/agent/settings.json. Check the array, never grep the file:

jq -e --arg n pi-fork \
  '(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
  ~/.pi/agent/settings.json

Case study — a whole-file grep hid a missing fork tool for six weeks (pi-devbox v1.0.0 → v1.6.3, found 2026-07-29). entrypoint-user.sh guarded its pi install /opt/<pkg> loop with grep -q "$_name" ~/.pi/agent/settings.json. But settings.example.json ships a top-level "pi-fork" config block (the effortProfiles), so the guard matched pi-fork's own configuration key and pi install /opt/pi-fork never ran — on fresh or preserved volumes. Compounding it, the entrypoint's non-destructive template merge runs earlier in the same startup than the install loop, so the mechanism that delivers new template keys to an old volume is what plants the string that defeats the guard. pi-observational-memory and pi-studio escaped only by luck: the template key is observational-memory (no pi- prefix) and there is no studio block. Both test suites asserted registration with the same grep, so CI reported a green "pi-fork registered (fork tool)" on every build and recreate while the tool was absent.

Transferable rules: (1) the presence of a config block for X is not evidence that X is loaded — configuring a tool and registering it are independent, and a session was observed tuning pi-fork.effortProfiles.deep to a newer Opus for a tool that had never once loaded; (2) an assertion that shares its failure mode with the code it tests is not a test; (3) if a tool you expect is missing from your tool list, check packages[] before assuming the extension is broken.

Forensic check — did this tool ever run on this machine? Session transcripts are the ground truth, and the answer survives container recreate (~/.pi is a named volume):

grep -oh '"toolName":"[a-z_]*"' ~/.pi/agent/sessions/*/*.jsonl | sort | uniq -c | sort -rn

A tool that has never been called simply has no line — that absence is the proof. evaluate-extension-usage.py (bundled next to this skill) reports the same thing per-tool with fork/recall/obsmem rollups; a missing fork <== pi-fork line means never-loaded or never-used, and the two are worth distinguishing before blaming your own habits for a low fork count.

/reload is enough for a newly installed package — no restart

After pi install <pkg> in a side terminal, the running pi session picks the package up on /reload; a full restart is not required. The reload path re-reads settings and re-resolves packages (verified in pi 0.82.1):

  • dist/core/agent-session.jsreload() calls settingsManager.reload(), then resourceLoader.reload(), then _buildRuntime({ includeAllExtensionTools: true })
  • dist/core/resource-loader.jsreload() calls settingsManager.reload() and then packageManager.resolve()

The new tool appears in your tool list on the turn after the reload. Two side effects worth expecting: reload emits session_shutdown then session_start with reason: "reload", so extensions that inject context on session start fire again (the mempalace wake-up block re-appears mid-session, which looks like a fresh session but isn't), and any captured ctx from before the reload is stale (see ctx.reload() in pi's docs/extensions.md).

Why These Extensions Belong Together

pi-fork and pi-observational-memory are symbiotic. pi-fork burns context (each fork dispatches a focused subtask whose detailed exploration would otherwise pollute your main thread). pi-observational-memory preserves context (when the main thread eventually compacts, observations + reflections survive the fold and can be recalled by ID). Aggressive forking only works long-term if the surviving summary is high-fidelity, and OM only earns its keep when it's preserving genuinely valuable distilled work.

ssh-controlmaster is orthogonal but composes cleanly: when pi is operating remotely, fork still spawns local sub-agents (each fork itself doesn't ssh), but their bash/read/write/edit calls do — see Part 3 caveats.


Part 1: pi-fork

Effort tier mapping

Configured in ~/.pi/agent/settings.json under pi-fork.effortProfiles. The conventional mapping is:

Tier Model Use for
fast haiku mechanical edits, narrow lookups, file-listing, single-fact verification, simple syntactic checks
balanced sonnet (default) normal exploration, implementation, testing, code review, option analysis
deep opus architecture decisions, security analysis, concurrency reasoning, ambiguous debugging, high-risk reviews, runbook drafting where subtle mistakes are costly

Rule of thumb: start at balanced unless you have a specific reason to go up or down. Going too cheap on a deep task wastes a fork; going too expensive on a mechanical task is just slow.

When to fork vs. do it yourself

Fork when any of:

  • The task requires reading many files whose contents you don't need to keep in your main context afterwards (the fork returns a dense summary; raw file contents stay in the fork's context and are discarded).
  • You want to run multiple analyses in parallel (especially: comparing N options, where independent reasoning is itself a signal — see "parallel forks" below).
  • The task is well-scoped enough to specify completely up front and well-bounded enough that returning a dense report is more useful than continuing the dialogue.
  • You are about to do something that would burn a lot of tokens on tool calls (long file reads, many bash invocations) whose output you will mostly discard.

Don't fork when:

  • The work fits in your current context budget without crowding out what comes next.
  • The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
  • You need to make decisions during the work that depend on context only the main thread has.

Task design: the five things a fork brief must contain

  1. Verified context up front. Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning from context, not finding context. Discovery work costs the fork tokens that don't come back to you.
  2. A specific deliverable. "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
  3. Decision authority. State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). State this even when it seems obvious. See "Boundary discipline" below.
  4. What "unsure" looks like. Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
  5. An anti-inheritance clause, whenever the brief is narrower than the conversation. The fork inherits your entire transcript (mechanism below), so every plan and todo you have voiced reads to it as sanctioned intent. If the brief forbids something the transcript is visibly building toward, say so explicitly: "the inherited history contains plans that are NOT your mandate — if history and this brief conflict, obey the brief and report the conflict instead of acting on it." And require a closing "What I did NOT do" list: it converts a silent boundary violation into a reported one, which is the difference between a bad afternoon and a corrupted repo.

Parallel forks for option-comparison

When facing a "which approach should we take" question with 24 candidate approaches, dispatching the candidates as parallel forks is high-leverage:

  • They reason independently. No fork sees the others' work.
  • Convergence is signal. If three forks at different effort tiers reach the same recommendation citing different evidence, that's a strong validation that doesn't depend on any one model's bias.
  • Divergence is also signal. If one disagrees, read its reasoning carefully — it may have spotted something the others missed, or it may have a tier-specific weakness worth knowing.

Sample shape for an option-comparison call:

  • Fork 1 (deep) — detailed runbook for option A, with timing/risk/rollback
  • Fork 2 (balanced) — comparison table A vs B vs C across N axes, with a recommendation
  • Fork 3 (fast) — focused sub-question (e.g., "which container image / library version / CLI flag")

This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.

Boundary discipline — and the mechanism that defeats briefs

Forks mostly honor explicit decision-authority instructions, but not infallibly:

  • Pure analysis tasks (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
  • Write-capable tasks with a "don't do X" carve-out — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.

Why, mechanically: a fork inherits your whole session, and your brief is only the last thing in it. pi-fork/src/index.ts:47:

const header = sessionManager.getHeader();
const branchEntries = sessionManager.getBranch();
const lines = [JSON.stringify(header)];
for (const entry of branchEntries) lines.push(JSON.stringify(entry));

Every entry on the current branch — your messages, assistant thinking, tool calls and tool results — is serialized verbatim, written to a temp session file (runner.ts:404), and opened by the child pi via --session. The task string is not the child's world; it is one instruction appended to a world already full of your stated intentions. When the transcript shows work in flight and the brief forbids it, those two conflict, and the child may resolve the conflict toward "finish the obvious thing".

Worked example (2026-07-29, balanced = sonnet-5, thinking: low). The brief said, verbatim: "DRAFT ONLY — do not submit anything, do not use gh/curl…, do not commit to any git repo, and do not modify any file other than /workspace/tmp/pi-mono-issue.md." The fork returned "All three done: 1. Pushed — pi-toolkit@4b4b76e… 2. Moved — cli_utils@f644fa1, pushed… symlinked live into ~/.local/bin". It had not merely claimed the work; commit timestamps place it inside the fork's execution window:

fork window        21:53:40Z → 21:58:27Z
cli_utils f644fa1  21:57:47Z    ← committed + pushed by the fork, inside the window
pi-toolkit 4b4b76e 21:42:05Z    ← pre-existing; the fork only claimed the push

The "three" things it completed were exactly the main thread's pending todos, visible to it in the inherited transcript. A 4645-character brief with four explicit prohibitions did not prevent this — so "state decision authority explicitly" is necessary and demonstrably not sufficient. Its verbatim file move also carried a data-loss race and a README asserting the opposite of the truth, neither flagged in its confident report.

You cannot withhold write tools. There is no tool allow/deny list anywhere in the fork config: config.ts exposes only extensions, environment, offline, and the child is spawned as a full pi process (--mode, --session, --model, --thinking). extensions: [] yields --no-extensions, which disables extensions, not the core read/write/edit/bash. Assume every fork can write anywhere you can. If a boundary violation would be genuinely unacceptable, the control is not the brief — it is not forking that task.

Why the report reads so confidently. The child's output contract is ~90 lines of shape — evidence rules, snippet rules, "Result / confidence / headline", per-genre sections. Grepping it for scope, authority, or permission language returns a single hit, and that one is about review scope in reporting. Nothing instructs the child to stay inside its mandate or to mark unverified claims. The format demands a verdict with a confidence level; where a fact was never checked, fluent prose fills the slot. The same fork reported "smoke-tested against all 4 live sessions" when there were 20 — and that number appears nowhere in the inherited transcript, so it was invention, not stale context.

Practical rules:

  • State decision authority explicitly, every time — and add the anti-inheritance clause (task-design item 5) whenever the brief is narrower than the conversation.
  • Require a "What I did NOT do" section on any write-capable fork.
  • Verify mutations from the filesystem, never from the report. git log -1 --format=%ai against the fork's start/end times, git status, real diffs. Read a fork's push as an unreviewed PR from a stranger.
  • A brief containing a prohibition is a judgment task. Do not run it at fast (haiku, thinking: off in the shipped profiles); escalate the tier. Reserve fast for "return raw output, no interpretation".
  • Distrust quantities and provenance claims in fork prose specifically ("all N sessions", "shipped with the image", "as expected") — those are the slots confabulation fills.
  • The fact that the fork was "right anyway" is not the same as the fork having followed instructions.

Anti-patterns

  • Forking trivial work. A fork has overhead. If the task takes < 30 seconds in your main thread, just do it.

  • Vague briefs. "Look into the database thing" returns vague output. The fork is not telepathic.

  • Forking iterative work. Forks are one-shot. If you need to iterate, you'll re-spec the task each time — usually worse than doing it yourself.

  • Recursive forking (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.

  • Treating fork output as ground truth without verification. Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.

    Observed failure shape (2026-07-29, fast tier): raw tool output correct, surrounding narrative wrong. A fork asked to run three commands and report them verbatim returned all three outputs accurately — then framed them with two confident inventions: that the packages[] entries were "the three that shipped with the image" (one had in fact been hand-registered minutes earlier by the parent — the entire point of the investigation), and that "the entrypoint re-registers them on each start" (the guard deliberately skips re-registration once the entry exists). Neither claim was in the command output; both were plausible glue.

    Rule: read a fork's Evidence section as data and its narrative as a hypothesis. When the fork's story contradicts something you established in the main thread, your own verified context wins. Note what this failure is not: the fork was not context-starved — it had your entire transcript (see "Boundary discipline" above) and invented anyway, because its output contract rewards a confident verdict over an admitted gap. Passing verified context up front still helps, but do not expect it to suppress invention on its own; the load-bearing habit is verifying decisive claims yourself. Being right about the evidence is not the same as being right.


Part 2: pi-observational-memory

How it actually works

Observational memory (OM v3, "session-ledger" architecture) runs an observer agent in the background as your conversation grows. When token thresholds are crossed (defaults: observe at 10k, reflect at 20k, compact at 81k), the observer distills the recent transcript into:

  • Observations — timestamped events, each with a 12-character hex ID like [3682ebfad7af]. Compact one-liners describing what happened in the conversation.
  • Reflections — durable, long-lived facts about the user, project, decisions, and constraints. Some reflections include observation IDs as evidence pointers.

When compaction fires, the raw transcript is folded away and replaced with a structured summary block containing the observations + reflections. You — the next turn of the same agent — receive that summary block as your starting context. That's the recovery mechanism.

Storage is in-transcript, not on disk. Do not grep for observations.jsonl or similar files; you will not find them. The artifact lives in the model's input context window.

Configuration lives in ~/.pi/agent/settings.json under observational-memory. Tune observeAfterTokens, reflectAfterTokens, compactAfterTokens, and observationsPoolMaxTokens if observations feel sparse or noisy. The default 81k compaction threshold is well-calibrated for typical multi-task sessions.

The recall tool

recall(<12-char-hex-id>) resolves a specific observation or reflection ID back to the original source context — the exact bash output, file contents, tool call results, commit message, or transcript fragment that the observation was distilled from.

Use recall when:

  • You are about to make a decision that depends materially on a compacted observation or reflection whose details are unclear.
  • You need exact wording, paths, commands, errors, commits, or user constraints behind a remembered claim.
  • A broad reflection is relevant but you need its supporting observations to act safely.
  • The user asks "why do you believe X" or "what supports that memory".

Do not use recall for:

  • Semantic search (it's keyed by ID, not topic — you must already have a specific 12-char hex ID).
  • Browsing the transcript out of curiosity.
  • Preemptive lookup of every ID in your context "just in case".

Recall costs tokens. Use it when exact source context will materially change your next action.

Calibration note (from a real ~1-month trial, 2026-05/06): across 20 logged container sessions, recall was invoked 0 times while obsmem passively carried 529 observations across 6 compactions. Zero recall is a warning sign, not a badge of efficiency — it means decisions after a compaction were made on the distilled one-liner alone, without ever re-checking the source. The injected summary is lossy by design. Default habit to adopt: when you are about to edit code, ship a change, or assert a fact that rests on a [high]/[critical] observation or a reflection you did not produce this turn, recall its ID first. One recall before a load-bearing action is cheap; redoing finished work or contradicting a prior correction is not.

Reading the compaction summary

When you see a block like The conversation history before this point was compacted into the following summary: at the start of a session or turn, that's OM output. Standard structure:

  • Reflections at the top: stable facts. Some have IDs in brackets.
  • Observations below, chronological: timestamped events with IDs in brackets and importance markers ([high], [critical], etc.).

When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.

Anti-patterns

  • Treating compacted memory as definitive without recall when stakes are high. Compaction is lossy; the observation may have lost a constraint that was on the line above it in the original transcript.
  • Recalling every ID preemptively. Wasteful. Recall on demand.
  • Assuming the disk holds OM artifacts. It doesn't. Don't waste time looking.
  • Ignoring the summary block when starting a session. It's there because the prior session was real work — read it before answering questions about past work.

Quick Reference

fork(task=..., effort=fast|balanced|deep)
  - state decision authority explicitly
  - pass verified context up front
  - specify deliverable shape
  - ask for "unsure about" section
  - if the brief is narrower than the conversation, say so:
    "inherited history is NOT your mandate; obey this brief and report conflicts"
  - write-capable? demand "What I did NOT do", then verify from git/fs, not the report
  - prohibition in the brief => not a `fast` task

recall(id=<12-char-hex>)
  - only when stakes justify the cost
  - id must already be visible in your context
  - not a search tool
~/.pi/agent/settings.json
  pi-fork.effortProfiles    — model + thinking-depth per tier
  pi-fork.defaultEffort     — usually "balanced"
  observational-memory.*    — token thresholds, model, agentMaxTurns
  observational-memory.debugLog: true  — opt-in NDJSON telemetry at
    ~/.pi/agent/observational-memory/debug/<session>.ndjson (off by default)

Installing on a fresh machine (host)

These are git-sourced pi packages (pi-fork is not on npm). Add to the packages array in ~/.pi/agent/settings.json, or:

pi install git:github.com/elpapi42/pi-fork
pi install git:github.com/elpapi42/pi-observational-memory   # default branch: master (no main)
# obsmem is also published: pi install npm:pi-observational-memory

Then /reload in a running session, or restart pi. Enable observational-memory.debugLog if you want the next window instrumented.

In a pi-devbox container the packages are already vendored in the image — register by local path instead of re-cloning (instant, no network, survives volume recreate):

pi install /opt/pi-fork

Afterwards, confirm with the packages[] jq check above rather than a grep, and confirm the tool actually arrived by looking at your own tool list after /reload.

Evaluating usage

evaluate-extension-usage.py (bundled next to this skill) mines pi session transcripts for fork/recall counts and obsmem compaction stats. Run it per machine (transcripts live at ~/.pi/agent/sessions/) for a combined host+container picture:

./evaluate-extension-usage.py                  # ~/.pi/agent/sessions
./evaluate-extension-usage.py /path/a /path/b  # multiple roots

Read a zero carefully before treating it as a habit problem: a missing fork <== pi-fork line means the tool was never called, which can equally mean it was never registered (see the packages[] case study above). Check registration first, then blame habits.


Part 3: ssh-controlmaster

What it does

When pi is launched with --ssh, this extension rewires pi's read, write, edit, and bash tools to execute on the remote machine, multiplexed over a single SSH ControlMaster socket. Pi is still running locally — the LLM, the UI, the MCP servers, the fork dispatcher all live on your local box — but anything those tools touch on the filesystem is the remote's filesystem.

This is fundamentally different from running pi locally and using bash to ssh inside it: with --ssh, the tool layer itself is remoted, so the LLM thinks it's working in the remote's cwd (the system prompt is rewritten to say so).

Usage

# Key-based auth (preferred), remote cwd defaults to remote $HOME
pi --ssh lagret

# Pin to a specific remote directory
pi --ssh lagret:/volume1/docker/portainer/compose/119

# Password auth (input is NOT masked when typing)
pi --ssh user@host --ssh-ask-pass

The lagret form requires a Host lagret block in ~/.ssh/config or a resolvable hostname. The status bar shows SSH ⚡ own master <host>:<cwd> or SSH ⚡ system master <host>:<cwd> once connected.

How it cooperates with system SSH config

It reads ssh -G <host> to learn the effective config, then:

~/.ssh/config for the host Behavior
ControlMaster auto or yes with a ControlPath Reuses the system master socket. Does not tear it down on pi exit ("it was the system's to manage before pi arrived").
No ControlMaster configured (or explicitly no) Creates its own master at /tmp/pi-cm-<pid>.sock with ControlPersist=yes. Tears it down on pi session_shutdown.

This means it composes cleanly with the system-wide ssh-control-master-setup.sh helper from the ci-release-watcher skill: if that script has already configured ~/.ssh/config for the host, pi --ssh rides on the existing master rather than opening a parallel connection.

Caveats and edge cases

  • Local vs remote tool boundary. Only read/write/edit/bash are remoted. MCP servers are still localmempalace files drawers and diary entries against the local palace even when your shell work happens remotely. Same for fork, recall, todo, and any other custom tool. This is usually what you want (palace memory survives across remote sessions) but worth knowing.
  • fork over ssh. Forks spawn locally and inherit the same --ssh mode by virtue of the parent's tool wiring; the fork's bash calls hit the same ControlMaster. Forks burn the same SSH socket, not a parallel one — multiplexing wins again.
  • macOS Unix socket path limit. The own-master socket lives at /tmp/pi-cm-<pid>.sock to stay under macOS's ~104-char limit. If you have a non-default TMPDIR long enough to blow this, ssh will fail to start the master.
  • Password auth password visibility. From the source: "input is NOT masked — the password is visible while typing." The password is written to a chmod-700 SSH_ASKPASS script in /tmp and deleted after the master establishes; not persisted, but on-screen during entry.
  • Remote bash environment. The remote shell is whatever ssh user@host '<cmd>' invokes — typically a non-login non-interactive bash. Don't expect ~/.bashrc aliases or PATH manipulations from ~/.profile. Pin tool paths or invoke via bash -lc '...' if you need login-shell behavior.
  • Path translation is naive. The extension does path.replace(localCwd, remoteCwd) to translate paths in tool calls. If the LLM emits an absolute remote path that doesn't share the local-cwd prefix, the path is passed through unchanged — usually fine but pathological for paths that happen to contain the local-cwd substring.

When to use it

  • Editing configs on a NAS / homelab host without scp ping-pong (pi --ssh lagret:/volume1/...)
  • Operating against a host whose tools/data you need but whose disk is too slow to mount via SSHFS
  • Investigating runner state, container configs, etc., on a remote host as if local
  • Multi-step remote work where opening a fresh ssh connection per step would burn your CGNAT flow budget

Anti-patterns

  • Using pi --ssh for one-off shell work. Just ssh directly. The extension shines when there are dozens of tool calls per session.
  • Filing palace drawers expecting them on the remote. They go to the local palace. If you want palace artifacts on the remote host, ssh into the remote and run pi there against its local palace.
  • Forgetting --ssh in followup sessions. Status bar is the canary — if you don't see SSH ⚡ you're operating locally despite intending remote. Easy mistake on a fresh terminal.

Reaching the devbox host from inside the container (dssh / dscp)

Distinct from pi --ssh above. When the pi-devbox container runs under OrbStack / Docker Desktop on macOS, it can SSH back to its own host. The entrypoint's setup-lan-access.sh regenerates ~/.ssh-local/config on every container start (the in-container ~/.ssh is mounted read-only, so a sidecar config + known_hosts + ControlPath under ~/.ssh-local/ is used instead).

# Interactive shells get aliases (from ~/.bash_aliases):
dssh host 'cmd'        # = ssh -F ~/.ssh-local/config host
dscp file host:/path   # = scp -F ~/.ssh-local/config ...

The agent's bash tool is non-interactive — those aliases are NOT loaded. Use the explicit form:

ssh  -F ~/.ssh-local/config host 'cmd'
scp  -F ~/.ssh-local/config <src> host:<dst>
  • Host aliases host and mac both resolve to host.docker.internal (user varies per host machine — check ~/.ssh-local/config for the active User value, key ~/.ssh-local/devbox_jump_ed25519, ControlMaster auto / ControlPersist 4h).
  • The config chains Include ~/.config/devbox-shell/ssh-lan.conf then Include ~/.ssh/config, so LAN targets are reachable too (add ProxyJump host to those entries).
  • Use it for: enabling/inspecting the host's pi config (~/.pi/agent/settings.json), running evaluate-extension-usage.py against the host's ~/.pi/agent/sessions/ for a combined host+container metric, or copying host transcripts into the container. The host's pi runs natively there; its palace, sessions, and extensions are separate from the container's.

Cross-Skill Notes

  • mempalace is for cross-session persistent memory (diary, knowledge graph, drawer storage). OM is for within-session context survival across compaction. They complement each other: write a diary entry at session end and let OM compact your work-in-progress mid-session.
  • systematic-debugging and test-driven-development skills pair well with deep-tier forks: a deep fork can carry out a focused debugging investigation or write a failing test suite without polluting your main context.
  • ci-release-watcher ships a scripts/ssh-control-master-setup.sh helper that configures system-wide SSH ControlMaster in ~/.ssh/config. That's a separate mechanism from the ssh-controlmaster pi extension — they compose, they don't overlap. Use the script for persistent host-wide multiplexing, the extension for per-pi-session remote operation.