Compare commits

..

40 Commits

Author SHA1 Message Date
Joakim Persson 13e67599c4 release: v1.2.1 — fallback skills + mempalace directive
Publish Docker Image / smoke-studio (push) Successful in 6m27s
Publish Docker Image / build-variant (push) Successful in 16m16s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / build-variant-studio (push) Successful in 21m13s
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 7s
Publish Docker Image / build-base (push) Successful in 46m21s
Publish Docker Image / smoke (push) Successful in 3m43s
Bake pi-extensions + mempalace skills into the image (available without a
mounted skillset) and add the mempalace session-start proactive-load directive
so frequently-recreated containers actually pick the skill up. Closes the
fork/recall + mempalace under-utilisation gap.

CHANGELOG: [Unreleased] -> v1.2.1.
2026-06-23 16:02:57 +02:00
Joakim Persson 7551947466 feat(skills): add mempalace proactive-load directive for containers
Baking the mempalace fallback skill fixed *availability*, but mempalace had
no proactive-load directive anywhere (pi-toolkit's global AGENTS.md only
points to pi-extensions), so a new container would still surface it only via
description-matching — the same under-utilisation the pi-extensions directive
was created to fix.

Add a session-start pointer to the pi-devbox managed AGENTS.md block
(pi-global-AGENTS.append.md): gated to pi-devbox containers and conditional on
the MemPalace MCP tools being present. Memory continuity matters most in a
frequently-recreated container — the palace is its only cross-recreate memory.

- pi-global-AGENTS.append.md: '## Session start: load the mempalace skill'.
- smoke-test: assert the pointer merges into the global AGENTS.md at build.
- docs: VENDORED.md, README, CHANGELOG [Unreleased].

Now both skills are complete in pi-devbox: directive + skill file.
pi-extensions = directive (pi-toolkit) + baked skill; mempalace = directive
(this block) + baked skill.
2026-06-23 15:54:13 +02:00
Joakim Persson a7d6a7d235 feat(skills): bake pi-extensions + mempalace fallback skills
The pi-toolkit global AGENTS.md tells every pi session to read
~/.agents/skills/pi-extensions/SKILL.md at start (the fork/recall
under-utilisation fix), but that skill lived only in the private skillset
repo — so the pointer dangled in any container started without skillset
mounted. Bake fallbacks so the pointer always resolves.

- pi-extensions (Option 1 + Option 2, layered):
  * Canonical skill promoted to the public pi-extensions package repo under
    skill/ (separate commit there); co-located with the code it documents.
  * rootfs/ carries a committed snapshot (the floor).
  * Dockerfile.variant copies /opt/pi-extensions/skill/ over the snapshot
    after the pinned clone, so a normal build ships the fresh package copy
    (recorded via PI_EXTENSIONS_REF) and an old-ref/mirror build still ships
    the snapshot. Helper evaluate-extension-usage.py travels with it.
- mempalace (Option 2 only): snapshot in rootfs/. Its consumer skill has no
  public package home (mempalace-toolkit ships a different skill,
  opencode-mempalace-bridge), so no build-time refresh.
- entrypoint links both (only-when-absent; mounted skillset still wins).
- smoke-test: build-time presence + package-match check + runtime symlink
  assertions; readiness gate now waits on the last-linked skill.
- docs: skills/VENDORED.md (provenance + refresh), README, AGENTS.md,
  CHANGELOG [Unreleased].

Note: shipped in the NEXT release; v1.2.0 (run 409) predates this.
2026-06-23 15:32:04 +02:00
Joakim Persson d619a6e2ec fix(entrypoint,smoke): link image-baked skills early to fix smoke race
Publish Docker Image / resolve-versions (push) Successful in 21s
Publish Docker Image / base-decide (push) Successful in 7s
Publish Docker Image / build-base (push) Successful in 33m43s
Publish Docker Image / smoke-studio (push) Successful in 4m5s
Publish Docker Image / smoke (push) Successful in 5m42s
Publish Docker Image / build-variant (push) Successful in 15m55s
Publish Docker Image / promote-base-latest (push) Successful in 7s
Publish Docker Image / build-variant-studio (push) Successful in 17m45s
Publish Docker Image / update-description (push) Successful in 56s
The runtime 'pi-devbox-environment skill linked' smoke assertion failed in
CI run 408 (gating build-variant). Root cause: the skill-linking block ran
AFTER the pi-toolkit/extensions deploy, but the smoke readiness gate only
waits on pi-deploy markers (keybindings.json, mempalace.ts) — which land
before the skill symlink — so the assertion sampled too early.

- entrypoint-user.sh: move the image-baked-skills symlink loop to run early
  (before the pi deploy block), so it completes before any readiness marker.
  Still before the skillset deploy, so foreign-link semantics are unchanged.
- smoke-test.sh: add the skill symlink to the readiness gate as well.

Build-time checks (baked skill, append snippet, merged AGENTS marker) all
passed in 408; only the timing of the runtime check was wrong.
2026-06-23 14:29:52 +02:00
Joakim Persson 2abfee141b feat: image-baked agent skills + pi-devbox-environment skill (v1.2.0)
Publish Docker Image / smoke-studio (push) Failing after 4m5s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / smoke (push) Failing after 5m46s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / resolve-versions (push) Successful in 35s
Publish Docker Image / base-decide (push) Successful in 23s
Publish Docker Image / build-base (push) Successful in 41m32s
Ship skills inside the image (independent of any mounted skillset repo):
- rootfs/usr/local/share/pi-devbox/skills/<name>/ symlinked into
  ~/.agents/skills/ by entrypoint-user.sh (foreign-link, survives volume
  recreate, never clobbers a skillset/user skill of the same name).
- New pi-devbox-environment skill: persistence model, host/LAN SSH
  reachability, split-DNS mechanisms, interactive-vs-tool-shell alias
  gotcha, tmux 0-index, uv-first Python, pi-studio reachability. Agnostic
  to host OS / hostnames / domains / nameservers (discovered at runtime).
- Dockerfile.variant appends pi-global-AGENTS.append.md onto pi-toolkit's
  pi-global-AGENTS.md (single global slot) so the skill is loaded
  proactively; gated on /usr/local/lib/pi-devbox/. Idempotent.
- smoke-test: baked-skill + append-snippet + merged-marker presence and a
  runtime symlink assertion.
- docs: README 'Agent skills' section, AGENTS.md layout, DOCKER_HUB.md;
  moved studio-tex roadmap to v1.3.0.

pi 0.79.7 -> 0.79.10 (auto-resolved from npm latest at build).
2026-06-23 12:49:13 +02:00
pi c346a106a3 release: v1.1.7 — pi 0.79.8 → 0.79.9; ssh-lan.conf LAN-peer docs
Publish Docker Image / base-decide (push) Successful in 1m0s
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / build-base (push) Successful in 33m15s
Publish Docker Image / smoke (push) Successful in 3m32s
Publish Docker Image / smoke-studio (push) Successful in 3m47s
Publish Docker Image / build-variant-studio (push) Successful in 17m39s
Publish Docker Image / build-variant (push) Successful in 19m13s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 13s
2026-06-21 23:36:43 +02:00
joakimp 8de0fad776 docs(lan): document ssh-lan.conf for naming LAN peers
The host-owned, bind-mounted ~/.config/devbox-shell/ssh-lan.conf is the
intended place to add `ProxyJump host` overrides for named LAN peers (so
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
documented in .env.example and the setup-lan-access.sh header — never in the
README, where someone hitting "can't reach LAN peers" actually looks.

- README: add a "Naming LAN peers" subsection under the macOS LAN-peers
  troubleshooting block, with a ProxyJump example and the read-only ~/.ssh
  caveat; add a pointer to it from the SSH and ControlMaster section.
- setup-lan-access.sh: correct the INCLUDE_BLOCK comment that suggested adding
  ProxyJump to the read-only ~/.ssh/config; point at ssh-lan.conf instead.
- CHANGELOG: note under Unreleased.

Docs/comment only — no behavior change.
2026-06-21 00:23:29 +02:00
pi ed49b8d97a fix(ci): resolve-versions needs shell: bash for 'set -o pipefail'
Publish Docker Image / smoke (push) Successful in 9m0s
Publish Docker Image / build-variant-studio (push) Successful in 17m41s
Publish Docker Image / build-variant (push) Successful in 19m1s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 45m54s
Publish Docker Image / smoke-studio (push) Successful in 3m43s
The default run shell is 'sh -e {0}' (dash on the act runner), which
rejects 'set -o pipefail' ('Illegal option -o pipefail') — failing the
resolve-versions job on line 2 and cascading every dependent job to
skipped (v1.1.6 run 401). The heavy build steps already declare
'shell: bash'; the resolve step did not. Added it.
2026-06-19 18:26:04 +02:00
pi 9eff3f3c48 release: v1.1.6 — build provenance + reproducibility hardening; pi 0.79.7 → 0.79.8
Publish Docker Image / resolve-versions (push) Failing after 52s
Publish Docker Image / base-decide (push) Has been skipped
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Has been skipped
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / smoke (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Adds OCI labels + /etc/pi-devbox/build-manifest.json so a published tag is
self-describing and reconstructable after CI logs rotate (manifest is
written from the actual checked-out HEAD of each /opt clone + live
pi --version, not just the intended build-args).

Hardens the build plumbing:
- scripts/check-base-hash.sh guards the base-rebuild invariant: every
  floating ARG *_REF in Dockerfile.base must be folded into the base_tag
  hash, else a ref-only change silently fails to rebuild the base
  (v1.1.2-class staleness footgun). Runs in base-decide and locally.
- resolve-versions now fails loud instead of falling back to a floating
  main/master on a transient API failure — validates each ref is a 40-hex
  SHA (and pi a real semver) and aborts the release otherwise.
- The three gitea companions (pi-toolkit, pi-extensions, mempalace-toolkit)
  gained overridable *_REPO build-args (defaulting to the canonical gitea
  origin) so a relocated/forked build can repoint them without editing the
  Dockerfiles — matching the existing PI_FORK_REPO/PI_OBSMEM_REPO pattern.

README documents the forked/relocated build-arg trick and how to read the
labels + manifest. smoke-test asserts the manifest + labels. pi bumps
0.79.7 → 0.79.8 (auto-resolved at build).
2026-06-19 18:23:11 +02:00
Joakim Persson a0abacaafb fix(ssh): survive read-only ~/.ssh ControlPath; render sidecar on all host OSes
Publish Docker Image / smoke (push) Successful in 3m22s
Publish Docker Image / smoke-studio (push) Successful in 3m42s
Publish Docker Image / build-variant (push) Successful in 15m29s
Publish Docker Image / update-description (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 16m49s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 33m44s
Coordinated with the pi-extensions ssh-controlmaster fix (picked up at build via
PI_EXTENSIONS_REF=main), this makes `pi --ssh <host>` and `dssh`/`dscp` robust
to a user ~/.ssh/config whose per-host ControlPath points under the read-only
~/.ssh bind-mount (e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`). A system default can
never override a user's per-host value, so the fix lives in two layers.

- setup-lan-access.sh: always render the writable ~/.ssh-local/config sidecar
  (Host * ControlPath redirect into ~/.ssh-local/cm + Include ~/.ssh/config) on
  EVERY host OS. Previously the script exited early (no-op) on native Linux,
  leaving dssh/dscp broken when ~/.ssh was read-only there too. The host-jump
  block, its key generation, and the authorize hints stay gated on VM-backed
  detection / DEVBOX_LAN_ACCESS=jump (new NEED_JUMP flag).
- Dockerfile.base: document that the /etc/ssh drop-in default cannot override a
  user per-host ControlPath; cross-ref the two handling layers.
- entrypoint-user.sh: correct the now-stale "no-op on native Linux" comment.
- README.md / DOCKER_HUB.md: document read-only-~/.ssh ControlPath handling.

CHANGELOG: v1.1.5 (Fixed + Changed + pi 0.79.6 -> 0.79.7 auto-resolved bump).
2026-06-18 21:59:18 +02:00
Joakim Persson da7d70825e docs(changelog): add v1.1.4 entry (AGENTS.md autoload, settings merge, history fix)
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 42m4s
Publish Docker Image / smoke-studio (push) Successful in 3m39s
Publish Docker Image / smoke (push) Successful in 5m20s
Publish Docker Image / build-variant (push) Successful in 18m8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 10s
Publish Docker Image / build-variant-studio (push) Successful in 24m41s
2026-06-17 20:52:05 +02:00
Joakim Persson 41c2c2b716 feat(entrypoint): non-destructively merge new template keys into settings.json
The settings.json bootstrap only fires when the file is ABSENT, so a
settings.json on a preserved named volume never picks up config added in a
later image (e.g. the observational-memory / pi-fork blocks, a newly-enabled
model). Users had to hand-merge after every upgrade.

On start, when settings.json already exists, deep-merge the template into it
with 'jq -s ".[0] * .[1]"' (template first, live second) so the user's values
always win and only MISSING keys are filled from the template. Arrays are
leaves (a model the user removed is not re-added). Rewrites only when the
merge changes something, backs up the original first, and skips safely (no
clobber) if either file is invalid JSON. Opt out with PI_SETTINGS_MERGE=0.

Add a recreate-sanity-check assertion that settings.json carries the
observational-memory + pi-fork blocks after recreate.
2026-06-17 20:49:41 +02:00
Joakim Persson 5c08bfc8a8 fix(shell): don't export DEVBOX_HIST_SET so nested shells flush history
The history-flush guard was exported, so it leaked into child processes.
Any nested shell -- crucially each tmux pane (which inherits the tmux
server's env) -- then saw the guard already set and skipped installing
'history -a' in PROMPT_COMMAND. Those shells only persisted history on a
clean exit, so abrupt termination (docker stop, tmux kill-server, SIGKILL)
silently lost their in-memory history. zoxide was less affected (its hook
is installed unguarded and writes immediately).

Make the guard shell-local (drop 'export') so every new interactive shell
re-installs its own per-prompt flush. Add a recreate-sanity-check assertion
that a nested login shell still wires up 'history -a'.

Storage was never the issue: ~/.cache/bash (devbox-shell-history) and
~/.local/share/zoxide (devbox-zoxide) are both persistent named volumes.
2026-06-17 17:22:30 +02:00
Joakim Persson 1371584634 sanity-check: verify global AGENTS.md symlink after recreate
pi-toolkit now symlinks pi-global-AGENTS.md -> ~/.pi/agent/AGENTS.md (pi's
global-instructions file, loaded at every start; directs the agent to read
the pi-extensions skill at session start). Add a recreate-sanity-check
assertion alongside the keybindings symlink check so a future image build
that bakes the new pi-toolkit verifies the wiring landed.
2026-06-17 16:58:18 +02:00
Joakim Persson d902b2d056 v1.1.3: add actual pi 0.79.5 release notes + document GitHub releases URL in AGENTS.md 2026-06-16 23:56:52 +02:00
Joakim Persson c48abf41d1 v1.1.3: pi 0.79.4 → 0.79.5
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 3m6s
Publish Docker Image / smoke-studio (push) Successful in 9m55s
Publish Docker Image / build-variant (push) Successful in 15m18s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 25m41s
2026-06-16 23:54:05 +02:00
pi 777d53354f docs(AGENTS): document GITEA_ACCESS_TOKEN env for general Gitea API access
GITEA_ACCESS_TOKEN + GITEA_HOST (passed from host .env via compose,
primarily for gitea-mcp) are also usable for any direct Gitea API work —
run inspection, tag checks — not just ci-release-watcher. Prefer over a
PAT file when present; host-managed lifecycle, nothing to revoke. Release
checklist step 7 now notes the env-token alternative.
2026-06-15 22:30:36 +02:00
pi 52fe09d79d release: v1.1.2 — pi 0.79.3 → 0.79.4
Publish Docker Image / resolve-versions (push) Successful in 29s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 43m23s
Publish Docker Image / smoke-studio (push) Successful in 3m52s
Publish Docker Image / smoke (push) Successful in 5m26s
Publish Docker Image / build-variant (push) Successful in 17m55s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Publish Docker Image / build-variant-studio (push) Successful in 25m38s
Patch release per AGENTS.md versioning scheme (pi version bump + smaller
fixes). Cut Unreleased → v1.1.2 (2026-06-15): pi 0.79.3→0.79.4
(CI-auto-resolved), the mempalace-toolkit SHA-resolution build fix,
recreate-sanity-check.sh maintainer tooling, and the AGENTS/README docs.
Bumped the README sanity-check version example to 0.79.4.
2026-06-15 22:16:52 +02:00
pi c9534c639f docs(AGENTS): add post-recreate sanity check to release-day checklist
Step 3 now runs scripts/recreate-sanity-check.sh inside the running
container after a local recreate — the runtime peer of the build-time
smoke-test.sh gate. Verifies persisted volumes survived and pi wiring
re-deployed, not just that the container booted.
2026-06-15 22:11:17 +02:00
pi 4ed6764323 Add runtime post-recreate sanity check (peer of smoke-test.sh)
scripts/recreate-sanity-check.sh verifies what is actually live in a
recreated container — persisted volumes, pi runtime wiring (keybindings,
extensions, mempalace.ts bridge, settings.json, fork/obsmem/studio
registrations), /tmp/sshcm, skel defaults, /opt toolkits. smoke-test.sh
runs at build time with --entrypoint="" and cannot see any of this.

Variant (studio/plain) auto-detected via /opt/pi-studio. pi version is
asserted only with --expected-version (built from 'latest', no Dockerfile
pin to self-derive). Maintainer tooling, not baked into the image.

Documented in README and CHANGELOG.
2026-06-15 22:04:02 +02:00
pi f8da7890df docs(mempalace-broker): clarify engine vs shim — what the image must still ship 2026-06-14 18:20:39 +02:00
pi b17dc1fa1f docs: add single-writer MemPalace broker design (RFC, queue #4) 2026-06-14 18:06:47 +02:00
pi 3eec9bc23c docs: correct mempalace anyOf workaround watch-target (PR #1735 is dead)
Parity with opencode-devbox: PR #1735 (the diary_write root-anyOf fix) was
closed UNMERGED on 2026-06-11, so the old "remove once PR #1735 ships" TODO
pointed at a dead PR. Issue #1728 is still open; PR #1717 is the current live
candidate; mempalace PyPI latest is still 3.4.0 (== our pin), so the
workaround stays.

- Dockerfile.base: rewrite the upstream-tracking comment + TODO (#1735 dead,
  watch #1717, removal trigger = a PyPI release > 3.4.0 stripping root anyOf).
- CHANGELOG: Unreleased Docs entry.

Docs-only; no behavior change.
2026-06-14 15:52:34 +02:00
pi 4744f05232 ci: CI-resolve mempalace-toolkit to a pinned SHA
mempalace-toolkit is the only companion cloned in Dockerfile.base (all
others live in Dockerfile.variant), so it bypassed the resolve-versions ->
build-arg plumbing and its ref stayed a literal `main`. Because the base
only rebuilds on a content hash of Dockerfile.base + rootfs/* + entrypoints,
a toolkit-only fix would silently fail to land unless Dockerfile.base itself
changed (as it incidentally did in v1.1.1).

Changes:
- resolve-versions: new mempalace_toolkit_ref output (gitea commits API,
  mirrors pi-toolkit resolution; jq '.[0].sha // "main"' fallback).
- base-decide: needs resolve-versions; fold the resolved SHA into the
  base-tag hash so a moved toolkit forces a base rebuild automatically.
- build-base: needs resolve-versions; pass --build-arg MEMPALACE_TOOLKIT_REF.
- Dockerfile.base: switch clone from `git clone --branch` to a SHA-capable
  `git fetch <ref> + checkout FETCH_HEAD` (the --branch <SHA> footgun
  already fixed in Dockerfile.variant, run 374).

base_tag now reflects a live gitea lookup; on API blip it falls back to
`main`, triggering one extra rebuild, never a missed one.

No new tag — lands on the next v* release or workflow_dispatch.
2026-06-14 15:11:22 +02:00
pi 314c3767a8 release: v1.1.1 — pi 0.79.3 + mempalace-mcp hang fix
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / build-base (push) Successful in 33m29s
Publish Docker Image / smoke (push) Successful in 3m20s
Publish Docker Image / smoke-studio (push) Successful in 3m38s
Publish Docker Image / build-variant (push) Successful in 15m27s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / update-description (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 16m51s
2026-06-13 23:59:25 +02:00
pi 05e88c5c75 fix: mempalace-mcp uninterruptible hang resolved via toolkit ext timeout
The per-request timeout + stall-kill landed in mempalace-toolkit's
mempalace.ts pi extension (commit a3b8829), which the base clones at
build via MEMPALACE_TOOLKIT_REF=main. A base rebuild picks it up.

- CHANGELOG: move from 'Known issues' to 'Fixed'; document the env knobs
  (MEMPALACE_MCP_TIMEOUT_MS / MEMPALACE_MCP_INIT_TIMEOUT_MS) and why the
  standalone stdio-watchdog shim was dropped.
- Dockerfile.base: replace the TODO with a note pointing at the fix.
2026-06-13 23:49:36 +02:00
pi 7f67c36a1c docs: capture mempalace-mcp uninterruptible-hang diagnosis (2026-06-13)
Symptom: pi TUI blocks on a mempalace tool call, ESC does not abort.
Initial WAL-contention hypothesis ruled out (no other writer running).
Likely cause: virtiofs cold open of chroma.sqlite3 stalls the JSON-RPC
initialize handshake; pi has no per-call MCP timeout.

Recovery today: docker exec <ctr> pkill -9 -f mempalace-mcp, restart pi.

Planned fix (deferred until after opencode-devbox pi removal): stdio
watchdog shim with per-REQUEST timeout. A naive process-lifetime
timeout wrapper is wrong because mempalace-mcp is long-lived.

Sharing the palace across harnesses remains the goal.
2026-06-13 16:18:45 +02:00
pi ab5ff8ec56 feat: bundle dot-watch helper for live graphviz .dot -> PNG re-render in Studio
pi-studio renders Mermaid natively but has no DOT renderer. Its markdown
preview displays local PNG/JPG/GIF/WEBP images, so dot-watch closes the
loop for Graphviz: edit .dot -> auto-render <name>.png -> Studio
refresh-from-disk shows the update. Uses mtime polling (no inotify dep).

- rootfs/usr/local/bin/dot-watch: the helper (executable)
- Dockerfile.base: COPY + chmod, following the studio-expose pattern
- README.md: 'Graphviz diagrams in Studio' subsection
- CHANGELOG.md: Unreleased entry

graphviz was already in the base image; no new package.
2026-06-11 16:25:27 +02:00
pi 421558477d docs(studio): add commented studio ports + STUDIO_EXPOSE to basic-shape compose 2026-06-11 13:20:44 +02:00
pi b655faab9f docs(studio): render network-hop figure as mermaid flowchart 2026-06-11 11:25:23 +02:00
pi 3b0335f34e docs(studio): clarify studio-expose foreground + token, add remote/mosh end-to-end recipe 2026-06-11 11:23:15 +02:00
pi f91dff6090 chore(release): promote CHANGELOG Unreleased -> v1.1.0 (2026-06-10)
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Successful in 42m12s
Publish Docker Image / smoke-studio (push) Successful in 3m46s
Publish Docker Image / smoke (push) Successful in 5m21s
Publish Docker Image / build-variant-studio (push) Successful in 16m56s
Publish Docker Image / build-variant (push) Successful in 18m0s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / update-description (push) Successful in 11s
2026-06-10 23:52:48 +02:00
pi 9ebb0643c7 docs: fix drift — sync compose/volumes, studio coverage, mempalace link
Audit found README/AGENTS carried a stale compose/volume set that
diverged from the shipped docker-compose.yml (DOCKER_HUB + compose +
.env.example were already consistent — README was the outlier):

- README compose block + 'Volumes and persistence' table: correct volume
  names (devbox-shell-history not -bash-history; devbox-uv at
  ~/.local/share/uv not devbox-uv-tools at /opt/uv-tools — the latter
  would SHADOW the baked mempalace install at UV_TOOL_DIR); add
  devbox-ssh-local + devbox-zoxide; mark devbox-palace/-chroma-cache
  optional; WORKSPACE_PATH/SSH_KEY_PATH (not HOST_WORKSPACE).
- README quickstart: 'compose exec -u developer' (no USER in image; bare
  exec lands a root shell).
- README: pi-studio now 'shipped' not 'planned'; build-pipeline + tag
  table cover -studio + smoke-studio/build-variant-studio.
- AGENTS: backward-compat volume names corrected; repo-layout bullets
  cover pi-studio install + studio-expose + STUDIO_EXPOSE bridge.
- DOCKER_HUB: MemPalace source link -> upstream MemPalace/mempalace
  (matches Dockerfile.base + CHANGELOG refs).

Note: the shipped v1.0.0 CHANGELOG migration note still lists the old
(incorrect) volume names; left as immutable released history.
2026-06-10 23:52:17 +02:00
pi 7d8ee4cea1 feat(studio): bundle studio-expose bridge + socat (opt-in STUDIO_EXPOSE)
pi-studio binds the container's 127.0.0.1, which a published Docker port
can't reach. Add a robust, portable bridge rather than a doc-only one-liner:

- Dockerfile.base: add socat (~1 MB, generally useful TCP relay).
- rootfs/usr/local/bin/studio-expose: socat TCP relay listening on the
  container's egress IPv4 (not 0.0.0.0 — that would EADDRINUSE against
  Studio's loopback listener) forwarding to 127.0.0.1:PORT on the SAME
  port, so Studio's printed token URL works verbatim. Robust egress-IP
  detection (hostname -I, loopback-filtered; ip route get fallback),
  --help, port validation, foreground.
- entrypoint-user.sh: opt-in STUDIO_EXPOSE=1 auto-starts the bridge in the
  background (studio variant only). Default OFF — Studio stays loopback-only
  (its secure default) unless explicitly opted in.
- README: 'Using pi-studio' now documents host-networking (A) and the
  studio-expose/STUDIO_EXPOSE bridge (B) with a security note; ssh -L for
  remote, mosh caveat retained.
- smoke-test: assert socat + studio-expose present (base-level).
- CHANGELOG/AGENTS updated.

No tag — stopping for review.
2026-06-10 23:33:44 +02:00
pi a78e59fb5b feat(studio): add :latest-studio variant (PR-3)
Bundle pi-studio (omaclaren/pi-studio) as a new -studio image variant:
browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs,
/studio command + studio_* agent tools.

- Dockerfile.variant: INSTALL_STUDIO + PI_STUDIO_REPO/REF args; vendor
  pi-studio to /opt/pi-studio (no build step — prebuilt client in git;
  npm install --omit=dev for 3 prod deps). STUDIO_PORT=8765 advisory.
- entrypoint-user.sh: register /opt/pi-studio via the existing pi install
  local-path loop (auto-skips in non-studio variant).
- smoke-test.sh: auto-detected studio assertions (clone + prebuilt client
  + pi install registration).
- CI: resolve PI_STUDIO_REF to a SHA; independent smoke-studio +
  build-variant-studio jobs that gate ONLY the -studio tags, so a studio
  failure never blocks the core :latest release.
- README: 'Using pi-studio' section documenting the container access
  reality — pi-studio hard-binds 127.0.0.1 (index.ts .listen(port,
  '127.0.0.1'), no --host flag), so -p publish alone can't reach it.
  Documents host-networking and loopback-bridge paths, the remote ssh -L
  forward, and the mosh caveat (no port forwarding; run parallel ssh -L).
- CHANGELOG/AGENTS/DOCKER_HUB updated. Will tag as v1.1.0 (minor).

No tag created — stopping for review.
2026-06-10 23:15:29 +02:00
pi cf5c60a342 fix(base): work around mempalace diary_write top-level anyOf
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 14s
Publish Docker Image / build-base (push) Successful in 33m49s
Publish Docker Image / smoke (push) Successful in 5m23s
Publish Docker Image / build-variant (push) Successful in 17m58s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Anthropic's tools API rejects top-level anyOf/oneOf/allOf in
input_schema. Mempalace 3.3.x/3.4.0 advertise diary_write with
`anyOf: [{required:[entry]}, {required:[content]}]`, breaking pi
on first prompt with:

  tools.<n>.custom.input_schema: input_schema does not support
  oneOf, allOf, or anyOf at the top level

Patch the installed mcp_server.py after `uv tool install` to drop
the anyOf block and require ["agent_name", "entry"] instead. The
handler still accepts `content` server-side as a kwarg alias, so
callers using either name keep working.

The workaround is idempotent and self-deactivating: once upstream
ships the fix the regex no longer matches and the RUN is a silent
no-op.

Also pin mempalace to 3.4.0 via a new MEMPALACE_VERSION build arg
so future bumps are a deliberate, reviewable diff rather than an
implicit pull of latest (an implicit upgrade is what swept the
broken schema in unannounced).

Refs MemPalace/mempalace#1728, MemPalace/mempalace#1735
2026-06-10 16:25:26 +02:00
pi edd6be1737 fix(ci): update-description must depend on resolve-versions
Run 376 published the Hub description with PI_VERSION=empty ("Current
:latest ships pi `` ") because the update-description job has
needs: [build-variant] but not resolve-versions. In Gitea Actions
needs.<job>.outputs.* only resolves for jobs in your needs: list.

The post-substitution sanity-check (grep -q '{{PI_VERSION}}') passed
because sed had successfully replaced the placeholder — with empty
string. Pre-empted by adding a non-empty assertion in this commit:
the step now fails loudly if PI_VERSION resolves to empty rather than
silently publishing a broken description.
2026-06-10 13:45:54 +02:00
pi efd254f4e6 docs: rewrite DOCKER_HUB.md for v1.0.0 reality + auto-inject pi version
The Hub description still described the pre-v1.0.0 reality (tags follow
pi npm version, builds FROM joakimp/pi-devbox:base-pi-only, opencode-
devbox lineage as source of truth) — none of that has been true since
v1.0.0 decoupled. End users on Hub got a misleading story.

DOCKER_HUB.md changes:
- Versioning section rewritten: semver from v1.0.0, with a deprecation
  note for the pre-v1.0.0 v{pi_version}[letter] scheme.
- New 'Build pipeline' section briefly explains the two-phase
  base/variant content-addressed structure so users understand what
  base-<hash> and base-latest tags are for.
- New 'Document and image tooling' section (pandoc, graphviz,
  imagemagick) added since these are new in v1.0.0 and broadly useful.
- Tealdeer noted (vs the old Node tldr).
- Tmux 0-indexing called out (relevant for future :latest-studio
  variant).
- Removed all 'pi-only build' / 'FROM base-pi-only' / 'opencode-devbox
  bakes the pi version' framing — pi-devbox is now self-contained.
- New {{PI_VERSION}} placeholder in 4 locations so the Hub description
  always shows which pi is in :latest.

Workflow change:
- update-description step now substitutes {{PI_VERSION}} placeholders
  in DOCKER_HUB.md before sending to Hub. PI_VERSION comes from the
  resolve-versions output (same one baked into the image), so the page
  and image can never disagree. Sanity-check fails the step if any
  unsubstituted placeholder remains.
2026-06-10 12:34:37 +02:00
pi 8b69b3625b fix: use git_fetch_ref for pi-toolkit and pi-extensions clones
The previous clone helper for these two repos (git_clone_retry) used
`git clone --branch <ref>`, which only accepts branch names or tags,
NOT commit SHAs. Run 374 (the workflow_dispatch retry of v1.0.0)
failed at smoke because the workflow's resolve-versions step had been
extended to resolve PI_TOOLKIT_REF and PI_EXTENSIONS_REF to commit
SHAs (commit b55b44e), and `git clone --branch <40-char-SHA>` fails
with 'Remote branch not found'.

Switching all four clones to git_fetch_ref (`git fetch + checkout
FETCH_HEAD`) makes the build accept both branch names AND SHAs
uniformly. Both Gitea and GitHub allow fetching arbitrary commits by
default (uploadpack.allowReachableSHA1InWant).

The unused git_clone_retry helper is removed; comment explaining the
choice and the historical context is in its place.

Image was published successfully on run 373; this only affects the
v1.0.0-rerun path (description fix). Image bytes unchanged because the
SHAs being passed match what run 373 cloned by branch name.
2026-06-10 12:28:09 +02:00
pi b55b44e7b6 ci: shorten Hub short-description to ≤100 bytes + resolve toolkit/extensions to SHAs
The v1.0.0 release run failed at update-description because Docker Hub's
short-description field has a 100-byte limit and the previous string
was 151 bytes (the em dash is 3 bytes UTF-8). The image itself shipped
fine — only the cosmetic Hub description patch failed.

Changes:
- Short description: 'Linux container with the pi coding-agent, MemPalace,
  and curated dev tooling.' (77 bytes, was 151)
- resolve-versions now also resolves pi-toolkit and pi-extensions main
  HEADs to commit SHAs so workflow_dispatch re-runs produce byte-identical
  images when those repos haven't moved. Fork+obsmem were already
  SHA-resolved; toolkit+extensions were branch-named (drift risk on
  re-runs that we got lucky on for v1.0.0).
2026-06-10 10:05:51 +02:00
22 changed files with 3611 additions and 187 deletions
+266 -16
View File
@@ -47,6 +47,7 @@ env:
jobs:
# ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide:
needs: [resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -57,6 +58,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Guard — base *_REF args must be folded into the base hash
run: bash scripts/check-base-hash.sh
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
@@ -75,6 +79,10 @@ jobs:
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
# resolves to a SHA; fold it in so base_tag changes when the
# toolkit moves (otherwise a toolkit-only fix never lands).
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
} | sha256sum | cut -c1-12
)
BASE_TAG="base-${HASH}"
@@ -114,31 +122,88 @@ jobs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps:
- name: Resolve pi version + fork/obsmem refs
- name: Resolve pi version + companion refs
id: resolve
shell: bash
run: |
set -eu
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm
# is not reliably on PATH in act_runner job containers.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
set -euo pipefail
AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
# Fail loud rather than silently shipping a floating branch. A
# transient network/API failure must ABORT the release, not bake
# an unpinned ref that defeats both cache-busting AND after-the-
# fact reproducibility. (Previously each lookup fell back to
# `main`/`master` via `|| echo`.)
require_sha() { # $1=label $2=value
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
exit 1
fi
}
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
# reliably on PATH in act_runner job containers, so query directly).
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true)
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
exit 1
fi
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
# Resolve pi-fork / pi-observational-memory git refs to commit
# SHAs so the build-arg string changes whenever upstream moves.
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
require_sha PI_FORK_REF "$FORK_REF"
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
[ -n "$FORK_REF" ] || FORK_REF=master
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
# requires auth even for public-repo commit listing.
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
# mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
# is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
# into the base-decide hash (see that job) to force a base rebuild
# when the toolkit moves — otherwise a toolkit-only fix silently
# fails to land unless Dockerfile.base itself changes.
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
# pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio.
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true)
require_sha PI_STUDIO_REF "$STUDIO_REF"
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
echo "Resolved PI_STUDIO_REF=${STUDIO_REF}"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
needs: [base-decide, resolve-versions]
if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest
container:
@@ -180,6 +245,7 @@ jobs:
shell: bash
env:
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
# 3-attempt retry around `docker buildx build --push` for transient
@@ -193,6 +259,7 @@ jobs:
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.base \
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
--push \
--tag "${BASE_TAG_FULL}" \
.; then
@@ -252,11 +319,75 @@ jobs:
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
# Additive + independent of the core `smoke` job: gates ONLY
# build-variant-studio, never the core build-variant. A studio build or
# smoke failure therefore cannot block the :latest / :vX.Y.Z release.
smoke-studio:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build amd64 studio variant for smoke
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: pi-devbox:smoke-studio
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
INSTALL_STUDIO=true
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
RELEASE_TAG=smoke-studio
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test studio (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
# ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant:
needs: [base-decide, smoke, resolve-versions]
@@ -301,10 +432,14 @@ jobs:
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
@@ -316,6 +451,101 @@ jobs:
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "PI_FORK_REF=${FORK_REF}" \
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
done
echo "==> All 3 build+push attempts failed"
exit 1
# ── Phase 4b: multi-arch publish of the studio variant ───────────────
# Additive: publishes :vX.Y.Z-studio (+ :latest-studio on release). Gated
# on its own smoke-studio, NOT on the core build-variant, so it can ship
# or fail independently of the core release.
build-variant-studio:
needs: [base-decide, smoke-studio, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute studio version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-studio"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-studio"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push studio variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.variant \
--push \
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "PI_FORK_REF=${FORK_REF}" \
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "INSTALL_STUDIO=true" \
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
@@ -374,7 +604,7 @@ jobs:
# ── Phase 6: update Hub description (only on real release runs) ────
update-description:
needs: [build-variant]
needs: [build-variant, resolve-versions]
if: |
always() &&
needs.build-variant.result == 'success' &&
@@ -385,7 +615,27 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Update Docker Hub description
env:
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: |
# Substitute {{PI_VERSION}} placeholders in DOCKER_HUB.md so the
# Hub page always shows which pi version is in :latest. The
# placeholder lives in DOCKER_HUB.md (committed); CI fills it
# at publish time using the same resolved version that was
# baked into the variant image. No drift between page and image.
if [ -z "${PI_VERSION}" ]; then
echo "::error::PI_VERSION env var is empty. Likely cause: the"
echo "::error::update-description job is missing 'resolve-versions'"
echo "::error::in its needs: list, so needs.resolve-versions.outputs.pi_version"
echo "::error::resolves to an empty string instead of the actual version."
exit 1
fi
cp DOCKER_HUB.md /tmp/hub-full.md
sed -i "s/{{PI_VERSION}}/${PI_VERSION}/g" /tmp/hub-full.md
if grep -q '{{PI_VERSION}}' /tmp/hub-full.md; then
echo "::error::DOCKER_HUB.md still contains unsubstituted {{PI_VERSION}} markers"
exit 1
fi
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
@@ -395,8 +645,8 @@ jobs:
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--arg short "Self-contained Linux container for the pi coding-agent — pi + companions + MemPalace + curated dev tooling. Decoupled from opencode-devbox at v1.0.0." \
--rawfile full /tmp/hub-full.md \
--arg short "Linux container with the pi coding-agent, MemPalace, and curated dev tooling." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
@@ -409,4 +659,4 @@ jobs:
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Description updated."
echo "Description updated (pi version: ${PI_VERSION})."
+68 -17
View File
@@ -12,25 +12,46 @@ re-brand of opencode-devbox's `pi-only` variant.
Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
`/etc/tmux.conf` with 0-indexed sessions.
- `Dockerfile.variant``FROM base-<hash>`, adds pi + companions
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`).
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
(`-studio` variant). Also appends the pi-devbox managed block from
`pi-global-AGENTS.append.md` onto pi-toolkit's `pi-global-AGENTS.md` (the
single global instruction slot pi loads) so containers proactively load the
baked `pi-devbox-environment` skill. Idempotent via a marker grep. After the
pinned clones it also refreshes the vendored `pi-extensions` fallback skill
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
(Option 1 over Option 2 — see `skills/VENDORED.md`).
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
deploy, mempalace-bridge symlink, fork/recall pi-install, skillset
deploy.
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), image-baked
skills symlink-in, skillset deploy.
- `rootfs/` — files baked into the image (bash aliases, inputrc,
setup-lan-access.sh).
setup-lan-access.sh, `studio-expose` helper). Also
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
symlinked into `~/.agents/skills/` by the entrypoint, available with or
without a mounted skillset — plus
`usr/local/share/pi-devbox/pi-global-AGENTS.append.md` (the global-AGENTS
pointer concatenated in `Dockerfile.variant`).
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
build-base → smoke → build-variant → promote-base-latest →
update-description).
update-description). The `-studio` variant adds independent
`smoke-studio` + `build-variant-studio` jobs that gate only the
`-studio` tags (never the core `:latest` release).
## Versioning scheme
- Tags follow semver. **v1.0.0** is the first decoupled release; future
minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow
minor bumps add variants (`-studio`, `-studio-tex`) or significant base
additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow
pi npm version updates and small fixes.
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`.
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
`joakimp/pi-devbox:latest-studio`.
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
`joakimp/pi-devbox:base-latest` (alias of most recent base).
@@ -38,17 +59,42 @@ re-brand of opencode-devbox's `pi-only` variant.
1. Confirm `pi --version` resolves from npm to the expected version
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
Check release notes at https://github.com/earendil-works/pi/releases for
the upstream changelog to include in `CHANGELOG.md`.
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
3. Verify `docker compose up` works locally with the current `latest` image
if you're upgrading users from a previous version.
if you're upgrading users from a previous version. Then run the
**post-recreate sanity check** inside the running container to confirm
persisted volumes survived and the pi runtime wiring re-deployed (not just
that the container booted):
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes.
6. Verify the Hub tags appear (latest + vX.Y.Z, plus base-latest if the
base was rebuilt this run).
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at
`gitea.jordbo.se/user/settings/applications`.
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
its lifecycle is managed host-side, nothing to revoke.
## Gitea API access (env token)
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
`${GITEA_HOST:-}`), primarily to enable the `gitea-mcp` server. They are
**not** baked into the image. When configured, they are also available for
**any** direct Gitea API interaction from inside the container — inspecting
CI runs, checking published tags, listing commits — e.g.
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=5"`.
Prefer this over a short-lived PAT file when the env token is present (the
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
unauthenticated too, so the token matters mainly for private repos or
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
revoke after use. Never echo the token value (including into logs).
## Cache-hit footgun (must-know)
@@ -108,12 +154,16 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## What we DON'T install (and why)
- **No texlive** (~600 MB1 GB). Users who need PDF export from pandoc
can install on demand: `sudo apt-get install texlive-xetex
or pi-studio can install on demand: `sudo apt-get install texlive-xetex
texlive-latex-recommended`. The planned `:latest-studio-tex` variant
will bake this in.
- **No pi-studio** (yet). Coming in v1.1.0 as the `:latest-studio`
variant. v1.0.0 is intentionally scope-limited to "decouple, don't
reshape."
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
vendored to `/opt/pi-studio` and registered at container start via
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
The default `:latest` image stays studio-free. Note: pi-studio binds
`127.0.0.1` inside the container, so browser access needs host
networking or the bundled `studio-expose` bridge (socat; auto-starts
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
Python REPLs; `apt install` other-language runtimes ad-hoc per
container if needed.
@@ -121,8 +171,9 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## Backward compatibility
- The host `~/.mempalace` bind-mount path is unchanged.
- Volume names (`devbox-pi-config`, `devbox-bash-history`,
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are
- Volume names (`devbox-pi-config`, `devbox-ssh-local`,
`devbox-shell-history`, `devbox-zoxide`, `devbox-nvim-data`,
`devbox-uv`; optional `devbox-palace`, `devbox-chroma-cache`) are
unchanged.
- `~/.pi/agent/` layout inside the container is unchanged; existing
named volumes work without recreation.
+506 -3
View File
@@ -11,9 +11,512 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## Unreleased
## v1.2.1 — 2026-06-22
_(no changes since v1.0.0)_
Patch release: close the fork/recall + mempalace **under-utilisation gap** in
containers started without the private `skillset` repo — bake the
`pi-extensions` and `mempalace` skills into the image and add the missing
mempalace session-start directive. pi version is re-resolved from npm `latest`
at build.
### Added
- **Vendored fallback skills: `pi-extensions` + `mempalace`.** The pi-toolkit
global `AGENTS.md` directs every pi session to read
`~/.agents/skills/pi-extensions/SKILL.md` at start (the fix for fork/recall
under-utilisation). That pointer dangled in a container started **without**
the private `skillset` repo mounted. The image now bakes fallback copies of
both skills under `/usr/local/share/pi-devbox/skills/`, symlinked in by
`entrypoint-user.sh` (only when absent, so a mounted skillset still wins).
- **Proactive-load directive for `mempalace`.** Baking the skill only fixes
*availability*; nothing in pi-toolkit's global `AGENTS.md` told sessions to
load it, so it would still surface only via description-matching. The
pi-devbox managed block (`pi-global-AGENTS.append.md`) now adds a
session-start pointer (gated to pi-devbox containers, conditional on the
MemPalace MCP tools being present) so a new container actually picks the
skill up — memory continuity matters most in a frequently-recreated
container. (`pi-extensions`'s directive already ships in pi-toolkit, so only
its skill file needed baking.)
- **Layered freshness for the `pi-extensions` skill (Option 1 + Option 2).**
The canonical skill was promoted into the **public `pi-extensions` package
repo** under `skill/` (co-located with the extensions it documents). A
committed snapshot in `rootfs/` is the *floor*; `Dockerfile.variant` copies
`/opt/pi-extensions/skill/` (the pinned, manifest-recorded clone) over it at
build, so a normal build ships the fresh package copy and an old-ref/mirror
build still ships the snapshot. `mempalace` is snapshot-only (its consumer
skill has no public package home — the `mempalace-toolkit` repo ships a
*different* skill, `opencode-mempalace-bridge`). Provenance + refresh steps:
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
- **Smoke-test coverage** for the fallback skills: build-time presence of both
`SKILL.md`s and the `pi-extensions` helper, a check that the baked
`pi-extensions` skill matches the package copy when the clone carries it, and
runtime assertions that both are symlinked into `~/.agents/skills/`.
---
## v1.2.0 — 2026-06-22
Minor release: **image-baked agent skills** — a new base mechanism that ships
skills inside the image (independent of any mounted skillset repo) — plus the
first such skill, `pi-devbox-environment`, and pi `0.79.9``0.79.10`
(auto-resolved from npm `latest` at build).
### Added
- **Image-baked agent skills.** Skills under
`/usr/local/share/pi-devbox/skills/<name>/` are now symlinked into
`~/.agents/skills/` by `entrypoint-user.sh` on every start, making them
available **with or without** a mounted `skillset` repo. The symlink points
at the image path (so it survives volume recreate, unlike anything baked
under a home dir a named volume would shadow) and is created only when
absent, so a same-named skillset skill or user override is never clobbered.
The skillset deploy classifies these as foreign-links and its `--prune-stale`
pass leaves them untouched.
- **`pi-devbox-environment` skill** (the first image-baked skill). Teaches
agents the container-shaped facts that are easy to get wrong: the
persistence/ephemerality tier model (what survives `down -v` / image
update), host + LAN SSH reachability and ControlMaster, split-horizon DNS
*mechanisms*, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`/
`cat``bat` don't exist in the non-interactive bash tool), the tmux 0-index
constraint, uv-first Python, and pi-studio reachability. Deliberately
environment-agnostic — host OS, hostnames, internal domains, and nameservers
are discovered at runtime, never hardcoded.
- **Proactive skill awareness via the global `AGENTS.md`.** `Dockerfile.variant`
appends a short, gated pointer (`pi-global-AGENTS.append.md`) onto
pi-toolkit's `pi-global-AGENTS.md` — the single global instruction slot pi
loads at startup — so containers load the `pi-devbox-environment` skill
proactively rather than only on description match. The pointer fires only
inside a pi-devbox container (checks for `/usr/local/lib/pi-devbox/`).
Build-time append is idempotent via a marker grep; runtime is unaffected
(the file is root-owned and re-symlinked by pi-toolkit each boot).
- **Smoke-test coverage** for the new mechanism: build-time presence of the
baked skill + append snippet + the merged marker in `pi-global-AGENTS.md`,
and a runtime assertion that `~/.agents/skills/pi-devbox-environment` is
linked after the entrypoint runs.
### Bumped: pi 0.79.9 → 0.79.10
Resolved from npm `latest` at build (v1.1.7 shipped `0.79.9`). See the
[pi changelog](https://github.com/earendil-works/pi/blob/main/CHANGELOG.md)
for the upstream `0.79.10` notes.
## v1.1.7 — 2026-06-21
Patch release: pi `0.79.8``0.79.9` (auto-resolved at build), plus the
`ssh-lan.conf` LAN-peer documentation that landed on `main` after v1.1.6.
Companion refs are auto-resolved to SHAs at build as before.
### Bumped: pi 0.79.8 → 0.79.9
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.9)):
- **Chat-template thinking compatibility** — OpenAI-compatible custom
providers can map pi thinking levels into `chat_template_kwargs`, enabling
vLLM/Hugging Face chat-template models (e.g. DeepSeek) to use
provider-native thinking controls.
- **GLM-5.2 provider improvements** — corrected Fireworks OpenAI-compatible
routing and OpenRouter `xhigh` thinking support, improving `/model`
behaviour and high-effort reasoning for GLM-5.2.
- **Fixes** — same-directory session switches now reuse imported extension
modules (fresh instances + lifecycle events preserved); deep session
branches no longer take quadratic time to build context; Markdown
streaming code-fence rendering no longer flickers on partial closing
fences; fuzzy `edit` matches preserve untouched line blocks instead of
rewriting the whole file; `/model` hides Copilot models unavailable to the
account and ranks exact provider-prefixed matches first.
### Docs: document `~/.config/devbox-shell/ssh-lan.conf` for naming LAN peers
The host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf` is the
intended place to add `ProxyJump host` overrides for **named** LAN peers (so
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
mentioned in `.env.example` and the `setup-lan-access.sh` header — never in the
README. Added a "Naming LAN peers" subsection to the README troubleshooting
block (plus a pointer from the SSH/ControlMaster section), and corrected the
stale `setup-lan-access.sh` comment that suggested editing the read-only
`~/.ssh/config` instead of `ssh-lan.conf`.
## v1.1.6 — 2026-06-19
Build provenance + reproducibility hardening, plus pi `0.79.7``0.79.8`
(auto-resolved at build). Companion refs are auto-resolved to SHAs at build
as before.
### Bumped: pi 0.79.7 → 0.79.8
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.8)):
- **Selective provider base entry points** — SDK users can pair
`@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with
explicit provider registration to keep bundled apps from including unused
provider transports.
- **Mistral prompt caching** — Mistral sessions use provider-side prompt
caching keyed on the pi session ID, with cached-token usage/cost
accounting.
- **Post-compaction token estimates** — compact results and compaction
events now include estimated post-compaction token counts.
- **OpenRouter Fusion alias** — `openrouter/fusion` available as a built-in
OpenRouter model alias.
### Added
- **Self-describing images: OCI labels + on-disk build manifest.** The
variant build now records exactly which pi version and companion-repo
commits were baked into each image. Previously the SHAs resolved by CI
only ever reached the build log (which rotates), so a published tag was
not reconstructable after the fact — confirming what shipped meant
triangulating from `git`, `pi --version`, and extension source.
- OCI labels: `org.opencontainers.image.{version,revision,created}` plus
`se.jordbo.pi-devbox.{pi,pi-toolkit,pi-extensions,pi-fork,pi-obsmem,mempalace-toolkit,pi-studio}-*ref`
inspect with `docker inspect`.
- `/etc/pi-devbox/build-manifest.json` written from **ground truth** (the
actual checked-out `HEAD` of each `/opt` clone + live `pi --version`),
not just the intended build-args, so it also exposes a clone that
silently resolved to the wrong ref. The provenance ARGs are declared
last so a changing `BUILD_DATE` never invalidates the expensive
install/clone layers.
- **`scripts/check-base-hash.sh` — base-rebuild invariant guard.** Every
floating `ARG *_REF` consumed by `Dockerfile.base` must be folded into the
`base_tag` hash, or a ref-only change won't trigger a base rebuild (the
v1.1.2 mempalace-toolkit staleness footgun). The guard fails CI the moment
someone adds an `ARG *_REF` to `Dockerfile.base` without folding it in; it
runs in the `base-decide` job and locally. Smoke-test gained assertions for
the manifest (present, no `"unknown"` components) and the OCI labels.
- **Overridable companion repo URLs.** The three gitea-hosted companions
(`pi-toolkit`, `pi-extensions`, `mempalace-toolkit`) gained `*_REPO`
build-args defaulting to their canonical `gitea.jordbo.se` origin —
matching the existing `PI_FORK_REPO` / `PI_OBSMEM_REPO` / `PI_STUDIO_REPO`
pattern. A relocated or forked build can now repoint a companion at a
mirror, another host, or a local path (`--build-arg PI_EXTENSIONS_REPO=...`)
without editing the Dockerfiles. Defaults are unchanged, so the canonical
CI build is byte-identical.
### Changed
- **`resolve-versions` now fails loud instead of falling back to a floating
branch.** Each pi-version / companion-ref lookup previously degraded to
`main`/`master` on a transient API/network failure (`|| echo "main"`),
silently shipping an unpinned ref that defeats both cache-busting and
reproducibility. Resolution now validates each result is a 40-hex commit
SHA (and pi a real semver) and aborts the release otherwise.
## v1.1.5 — 2026-06-18
Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6``0.79.7`
(auto-resolved at build). The `pi-extensions` ref is auto-resolved to `main`
HEAD at build, so the `ssh-controlmaster` fix below lands automatically.
### Fixed
- **`pi --ssh <host>` no longer fails with "Read-only file system" when the
user's `~/.ssh/config` sets a per-host `ControlPath` under the read-only
`~/.ssh` mount** (e.g. the common CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`).
Root cause: SSH precedence means a user's per-host `ControlPath` always wins
over the baked `/etc/ssh/ssh_config.d` default, so the master socket tried to
bind under the RO `~/.ssh` and `ssh … pwd` exited 255 ("Could not resolve
remote pwd"). The `ssh-controlmaster` extension (pulled from `pi-extensions`
`main` via `PI_EXTENSIONS_REF`) now (a) resolves the remote pwd with a direct
connection (`-o ControlPath=none -o ControlMaster=no`), and (b) tests whether
the system `ControlPath` dir is actually writable — falling back to its own
`/tmp` master (whose command-line `-o ControlPath` overrides the user's path)
when it is not. OS-agnostic and independent of whether the user uses
ControlMaster, so the majority of configs (no ControlMaster at all) are
unaffected.
### Changed
- **`setup-lan-access.sh` now renders the writable SSH sidecar
(`~/.ssh-local/config`) on every host OS, not just VM-backed ones.**
Previously the whole script no-oped on native Linux, so a Linux host that
also bind-mounts `~/.ssh` read-only got no `ControlPath` redirect. The
`ControlPath` redirect + `Include ~/.ssh/config` (and `dssh`/`dscp` usability)
now work on Linux too; only the host-jump block (`Host host mac`), its key
generation, and the authorize hints remain gated on VM-backed detection
(`DEVBOX_LAN_ACCESS=auto`) or `=jump`.
### Bumped: pi 0.79.6 → 0.79.7
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.7)):
- **Automatic theme mode** — `/settings` can choose separate light and dark
themes and follow terminal color-scheme changes (`/` is now reserved in
theme names for this).
- **Self-only `pi update` by default** — bare `pi update` updates pi only;
`pi update --all` updates pi and packages together.
- **Extension API helpers** — `CONFIG_DIR_NAME` exported so extensions resolve
project config paths without hardcoding `.pi`; edit-diff helpers
(`generateDiffString`, `generateUnifiedPatch`, `EditDiffResult`) exported.
- **Warp inline images** via Kitty graphics capability detection.
- Fixes: RPC unknown-command errors now include the request id (clients no
longer hang); `/model` autocomplete matches provider/model regardless of
token order; tree navigator horizontally pans deep entries.
## v1.1.4 — 2026-06-17
Patch release: config and shell-quality fixes on a preserved volume. No pi
version bump (still `0.79.6`, latest). The `pi-toolkit` ref is auto-resolved
to `main` HEAD at build, so the AGENTS.md change below lands automatically.
### Added
- **Global `AGENTS.md` auto-loads the pi-extensions skill.** `pi-toolkit` now
ships `pi-global-AGENTS.md` and symlinks it to `~/.pi/agent/AGENTS.md` (pi's
global-instructions file, loaded at every start). It directs the agent to
read the `pi-extensions` skill at session start and carries a core
fork/recall cheat-sheet, since on-demand skill description-matching was
leaving `pi-fork` / `pi-observational-memory` under-utilised. **Heads-up:**
on a preserved volume any pre-existing real `~/.pi/agent/AGENTS.md` is backed
up to `*.bak.<timestamp>` and replaced by the symlink (same behavior as
`keybindings.json`).
- **`settings.json` merge-on-recreate.** The bootstrap only ever copied the
template when `settings.json` was *absent*, so a file on a preserved volume
never picked up config added in a later image (e.g. the
`observational-memory` / `pi-fork` blocks, a newly-enabled model). The
entrypoint now deep-merges the template into an existing `settings.json` on
start with `jq -s '.[0] * .[1]'` (template first, live second): the user's
values always win and only *missing* keys are filled in. Arrays are treated
as leaves (a model the user removed is not re-added); the file is only
rewritten when the merge changes something, the original is backed up first,
and invalid JSON on either side is skipped rather than clobbered. Opt out
with `PI_SETTINGS_MERGE=0`.
### Fixed
- **bash history loss in nested / tmux shells.** The `DEVBOX_HIST_SET` guard
that installs the per-prompt `history -a` flush was `export`ed, so it leaked
into child processes. Any nested shell — crucially each tmux pane, which
inherits the tmux server's env — saw the guard already set and skipped
installing `history -a`, persisting history only on a clean exit. Abrupt
termination (`docker stop`, `tmux kill-server`, SIGKILL) then silently lost
that shell's in-memory history. The guard is now shell-local (no `export`),
so every new interactive shell re-installs its own flush. `zoxide` was less
affected (its hook is unguarded and writes immediately). History and zoxide
storage were never the issue — `~/.cache/bash` (`devbox-shell-history`) and
`~/.local/share/zoxide` (`devbox-zoxide`) are persistent named volumes.
**Note:** existing shells/panes keep the old behavior until restarted
(`tmux kill-server` or open fresh shells).
### Maintainer
- `scripts/recreate-sanity-check.sh` gained assertions for the new wiring: the
`~/.pi/agent/AGENTS.md` symlink, a nested login shell installing
`history -a`, and `settings.json` carrying the `observational-memory` +
`pi-fork` blocks after recreate.
---
## v1.1.3 — 2026-06-16
Patch release: pi `0.79.4``0.79.5` (auto-resolved at build).
### Bumped: pi 0.79.4 → 0.79.5
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.5)):
- **Provider-scoped API key environments** — `auth.json` API key entries can
now include `env` overrides for provider-specific Cloudflare, Azure OpenAI,
Google Vertex, Amazon Bedrock, cache retention, and proxy settings without
changing the project shell.
- **Global HTTP proxy setting** — configure `httpProxy` once in global settings
to apply `HTTP_PROXY` / `HTTPS_PROXY` to Pi-managed HTTP clients.
- **Vercel AI Gateway attribution** — requests now include Pi attribution
headers by default.
- **Fixes:** inherited OpenAI Responses streaming tolerates null message content
before tool calls; DeepSeek V4 thinking no longer sends both `thinking` and
`reasoning_effort`; device-code login no longer auto-opens the browser;
various Google/Vertex Gemini model metadata corrections; session selector
empty-state fix; Cursor Up history navigation fix.
---
## v1.1.2 — 2026-06-15
Patch release: pi `0.79.3``0.79.4` (auto-resolved at build), plus the
build-plumbing fix, maintainer tooling, and docs accumulated since v1.1.1.
### Changed
- **`mempalace-toolkit` is now CI-resolved to a commit SHA**, closing a
silent-staleness footgun. It is the only companion cloned in
`Dockerfile.base` (all others are cloned in `Dockerfile.variant`), so it
was never run through the `resolve-versions` → build-arg plumbing. Its
ref stayed a literal `main`, and because the base only rebuilds when the
hash of `Dockerfile.base + rootfs/* + entrypoints` changes, a
toolkit-only fix would *not* land in the image unless `Dockerfile.base`
itself happened to change (as it did, incidentally, in v1.1.1).
Now `resolve-versions` resolves `mempalace-toolkit` `main` HEAD to a SHA
(new `mempalace_toolkit_ref` output), `base-decide` folds that SHA into
the base-tag hash (so a moved toolkit forces a base rebuild), and
`build-base` passes it as `--build-arg MEMPALACE_TOOLKIT_REF`. The base
clone switched from `git clone --branch` to a SHA-capable
`git fetch <ref> + checkout FETCH_HEAD` (the `--branch <40-char-SHA>`
footgun previously fixed in `Dockerfile.variant`, run 374).
Note: `base-decide` now depends on `resolve-versions`, so the base tag
reflects a live gitea API lookup. On an API blip it falls back to `main`
— which hashes differently than a SHA and triggers one *extra* rebuild,
never a *missed* one (fail-toward-rebuild).
### Added (maintainer tooling, no image change)
- **`scripts/recreate-sanity-check.sh`** — runtime post-recreate sanity
check; the runtime peer of `smoke-test.sh`. Where `smoke-test.sh` runs at
build time with `--entrypoint=""` (and so can never see persisted volumes
or the entrypoint's runtime deploy), this verifies what is actually live
in the container *after* `docker compose up -d --force-recreate`:
persisted named volumes survived, the pi runtime wiring is intact
(keybindings symlink, ≥4 extensions, `mempalace.ts` bridge, `settings.json`,
and pi-fork / pi-observational-memory / pi-studio registrations),
`/tmp/sshcm` is mode 700, shell defaults re-seeded, and `/opt` toolkits
intact. Variant (studio/plain) auto-detected via `/opt/pi-studio`. Since
pi is built from `latest` (no concrete Dockerfile pin), the version check
asserts only when `--expected-version` is passed, else WARNs. Not baked
into the image — repo/maintainer tooling, same category as
`smoke-test.sh`. A short-name wrapper (`pi-devbox-sanity`) lives in
`cli_utils/bin`, kept separate from opencode-devbox's `devbox-sanity` so
hosts with only one devbox checked out stay self-contained.
### Docs (no image change)
- Correct the MemPalace `diary_write` anyOf workaround watch-target in
`Dockerfile.base`: upstream PR #1735 was **closed unmerged** (2026-06-11),
so the old “remove once #1735 ships” TODO pointed at a dead PR. Issue #1728
is still open; PR #1717 is the current live candidate; mempalace PyPI latest
is still 3.4.0 (== our pin), so the workaround stays. Removal trigger is now
a PyPI release > 3.4.0 that actually strips the root-level anyOf.
- Document the post-recreate sanity check: AGENTS.md release-day checklist
(step 3) now runs `scripts/recreate-sanity-check.sh` inside the recreated
container, and README gains a "Post-recreate sanity check" subsection
alongside the build-time smoke-test note.
---
## v1.1.1 — 2026-06-13
Patch release: pi `0.79.1``0.79.3` (auto-resolved at build) plus the
mempalace-mcp hang fix below.
### Fixed
- **`mempalace-mcp` no longer hangs the pi TUI uninterruptibly.** When
the palace is bind-mounted from the macOS host (OrbStack virtiofs) and
the container opened a large `chroma.sqlite3` for the first time, a
cold storage open / HNSW load could stall the server before it emitted
its JSON-RPC response. The awaiting promise then hung forever and the
TUI froze — ESC cancels the LLM stream, not a pending MCP tool call, so
there was no way out short of `docker exec <container> pkill -9 -f
mempalace-mcp` and restarting pi.
The fix lives in the `mempalace.ts` pi extension shipped by
**mempalace-toolkit** (cloned into the base at build time via
`MEMPALACE_TOOLKIT_REF`, default `main`): the JSON-RPC client now arms
a **per-request** timeout. On expiry it rejects the request *and* kills
the stalled child (SIGTERM→SIGKILL), so pi surfaces an error instead of
hanging; the bridge then marks itself unavailable so subsequent calls
fail fast (restart pi to retry). This is deliberately per-REQUEST, not
a process-lifetime `timeout 60 mempalace-mcp` wrapper — the long-lived
server is only killed when a request genuinely stalls.
Tunables (env): `MEMPALACE_MCP_TIMEOUT_MS` (tool-call timeout, default
`60000`), `MEMPALACE_MCP_INIT_TIMEOUT_MS` (initialize/tools-list
handshake, default `120000`); set either to `0` to disable. Requires a
base rebuild to pull the updated extension. The earlier plan of a
standalone Python stdio-watchdog shim was dropped: the extension
already owns request/response correlation, so a separate
framing-reparsing shim is unnecessary.
Still open (out of scope here): sharing one palace across harnesses
ideally wants a single host-side `mempalace-mcp` daemon multiplexing
stdio over a UNIX socket, so all clients share one writer on native
APFS rather than each cold-opening over virtiofs.
`mempalace-mcp` that applies a per-request timeout and kills the child
on stall, **without** killing the long-lived server itself (a naive
`timeout 60 mempalace-mcp` wrapper is wrong — it kills the server
mid-session). Sharing the palace across harnesses (native pi, container
pi, opencode) remains the goal — isolated palaces defeat the point.
Longer term: run a single mempalace-mcp daemon on the host and
multiplex stdio over a UNIX socket so all clients share one writer on
native APFS.
### Added
- **`dot-watch` helper** (`/usr/local/bin/dot-watch`) — auto-rerenders a
Graphviz `.dot` file to PNG on every save via mtime polling (no
`inotify` dependency). pi-studio renders Mermaid natively but has no
DOT renderer; since its markdown preview displays local PNG/JPG/GIF/WEBP
images, this closes the loop for Graphviz: edit `.dot``dot-watch`
regenerates `<name>.png` → Studio *refresh-from-disk* shows the update.
`graphviz` was already in the base image, so no new package. Baked into
`Dockerfile.base` following the `studio-expose` pattern; documented in
the README Studio section.
## v1.1.0 — 2026-06-10
### Added — `:latest-studio` variant
- **New `-studio` image variant** bundling
[pi-studio](https://github.com/omaclaren/pi-studio) — a two-pane
browser workspace (prompt/response editor, live KaTeX/Mermaid preview,
tmux-backed literate REPLs for Shell/Python/IPython/Julia/R/GHCi/Clojure)
plus the `/studio` slash command and `studio_repl_send` /
`studio_export_*` agent tools. Published as `:latest-studio` and
`:vX.Y.Z-studio` (multi-arch).
- pi-studio is **vendored to `/opt/pi-studio`** at build time (gated by
`INSTALL_STUDIO=true`, ref pinned via CI-resolved `PI_STUDIO_REF`) and
registered on container start by `entrypoint-user.sh` via
`pi install /opt/pi-studio` — the same pattern as pi-fork /
pi-observational-memory. No build step: pi-studio ships its browser
bundle prebuilt in git. The non-studio `:latest` image is unchanged.
- CI gains independent `smoke-studio` + `build-variant-studio` jobs that
gate **only** the studio tags, so a studio build/smoke failure can
never block the core `:latest` / `:vX.Y.Z` release.
- `STUDIO_PORT=8765` baked as an advisory default.
- **`studio-expose` helper + `socat` (base).** Because pi-studio binds the
container's loopback, a published Docker port can't reach it. The new
`studio-expose` helper (socat, added to the base) bridges the container's
loopback to its egress interface on the same port; set `STUDIO_EXPOSE=1`
in compose to auto-start it on boot (default off — Studio stays
loopback-only otherwise). `socat` is in the base for all variants.
- **README "Using pi-studio" section.** Documents the container access
reality: pi-studio hard-binds `127.0.0.1` inside the container
(`.listen(port,"127.0.0.1")`, no `--host` flag), so a plain `-p`
publish does not reach it. Documents the two working paths — host
networking (recommended on OrbStack) and a loopback bridge for bridge
networking — plus the remote `ssh -L` forward and the **mosh caveat**
(mosh cannot forward ports; run a parallel `ssh -L` alongside it).
## v1.0.1 — 2026-06-10
Patch release. Works around an upstream MemPalace bug that broke pi at
first prompt against the Anthropic Claude API.
### Fixed
- **`mempalace_diary_write` schema rejected by Anthropic API.** Mempalace
3.3.x and 3.4.0 advertise `diary_write`'s `input_schema` with a
top-level `anyOf: [{required:[entry]}, {required:[content]}]` to
express "either `entry` or `content` must be supplied". Anthropic's
tools API rejects top-level `anyOf` / `oneOf` / `allOf` outright, so
pi failed to register tools at session start with
`tools.<n>.custom.input_schema: input_schema does not support oneOf,
allOf, or anyOf at the top level`. `Dockerfile.base` now patches the
installed `mcp_server.py` after `uv tool install` to drop the `anyOf`
block and require `["agent_name", "entry"]` instead. The mempalace
handler still accepts `content` server-side as a kwarg alias, so
callers using either name keep working. Tracked upstream:
[issue #1728](https://github.com/MemPalace/mempalace/issues/1728),
[PR #1735](https://github.com/MemPalace/mempalace/pull/1735).
The workaround is idempotent + self-deactivating and will be removed
once a fixed mempalace release lands on PyPI.
### Changed
- **Mempalace pinned to 3.4.0** via `MEMPALACE_VERSION` build arg.
Future bumps must be a reviewable diff rather than an implicit pull
of `latest` (the broken 3.3.x/3.4.0 schema slipping in unannounced
is what caused this release).
## v1.0.0 — 2026-06-09
@@ -91,7 +594,7 @@ dependencies.
### Future work
- v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)).
- v1.2.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
- v1.3.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
## v0.79.0 — 2026-06-08
+81 -33
View File
@@ -1,15 +1,21 @@
# pi-devbox
A Docker container with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on top of [opencode-devbox](https://hub.docker.com/r/joakimp/opencode-devbox)'s base image. Pi gets a fully-loaded development environment in one `docker run`.
A self-contained Docker container for the [pi coding-agent](https://github.com/earendil-works/pi) — pi + companion repos + MemPalace + a curated set of dev tooling, ready to run.
> **Current `:latest` ships pi `{{PI_VERSION}}`** (resolved at build time; see [Versioning](#versioning)).
## Image variants
| Tag | Size (compressed) | What you get |
|---|---|---|
| `joakimp/pi-devbox:latest` | ~700 MB | Pi + companion repos, on top of the opencode-devbox base |
| `joakimp/pi-devbox:vX.Y.Z` | same | Pinned pi version (tracks the [pi npm package version](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) |
| Tag | Architectures | Size (compressed) | What you get |
|---|---|---|---|
| `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.1 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions |
| `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release |
| `joakimp/pi-devbox:latest-studio` | amd64, arm64 | ~1.15 GB | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio): browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs |
| `joakimp/pi-devbox:vX.Y.Z-studio` | amd64, arm64 | same | Pinned semver studio release |
| `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.0 GB | Base layer alias (internal building block; pull `:latest` instead) |
| `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.0 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. |
Multi-arch: `linux/amd64`, `linux/arm64`.
> **pi-studio (`-studio` tags):** launch with `/studio --no-browser --port 8765` inside a pi session. The server binds `127.0.0.1` **inside the container**, so reach it via host networking or a loopback bridge (and `ssh -L` for a remote host; mosh needs a parallel `ssh -L`). Full recipe: [README → Using pi-studio](https://gitea.jordbo.se/joakimp/pi-devbox#using-pi-studio--studio-variant).
## Quick start
@@ -38,42 +44,83 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
## What's inside
pi-devbox is a re-brand of the **pi-only build** — it builds
`FROM joakimp/pi-devbox:base-pi-only` and adds no layers of its own. That
building-block tag is produced by opencode-devbox's CI (from
`Dockerfile.variant` with `INSTALL_OPENCODE=false`) but published here, in the
pi-devbox repo, so an opencode-devbox tag never ships without opencode.
The pi-only build is lean
and pi-focused (no opencode — use `opencode-devbox:latest-with-pi` if you want
both).
Everything below is inherited from that single source of truth.
### pi and companions
Base tooling:
- **Debian trixie** (latest stable)
- **Node.js** (LTS), **uv** (Python tooling), **rustup** (Rust on-demand)
- **AWS CLI v2** + AWS Bedrock-ready config
- **MemPalace** + MCP server — persistent agent memory across sessions, queryable via `mempalace_*` tools inside pi
- **Gitea MCP** server
- **Dev tools**: neovim (LazyVim defaults), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make
- **Shell**: bash with history tuning, prefix-search bindings, fzf/zoxide integration
- **Host-OS-agnostic LAN access** — on VM-backed hosts (macOS OrbStack / Docker Desktop) the host is set up as an SSH jump to reach LAN peers (`dssh` alias; `DEVBOX_LAN_ACCESS`/`HOST_SSH_USER`). No-op on native Linux.
pi and companions:
- **pi** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — baked at `/usr/bin/pi`, version set by the pi-only base build
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
- **mempalace bridge** — MCP extension auto-symlinked so pi can read/write the same palace as opencode-devbox
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
### MemPalace (persistent agent memory)
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
### Document and image tooling
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
- **graphviz** (`dot`) — diagram rendering pipelines
- **imagemagick** (`magick`) — image conversion / resizing
### Modern CLI tooling
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
- **Search/nav**: ripgrep, fd, fzf, zoxide
- **Display**: bat, eza, htop, tree
- **Data**: jq, yq
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
- **Build**: gcc, g++, make, patch
- **Misc**: gosu, age, rsync, less
### Language toolchains
- **Python**: system Python 3 + **uv** (preferred) for fast Python package management. Run any Python REPL/notebook stack on demand without bloating the image:
```bash
uv run --with ipython ipython
uv run --with jupyterlab jupyter lab --no-browser --port 8888
uv run --with marimo marimo edit
```
- **Node.js** v22 + npm (used by pi itself)
- **Rust** — `rustup-init` is on PATH; install toolchains on demand
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
### Cloud + secrets
- **AWS CLI v2** — for SSO + Bedrock auth (pi's preferred LLM provider for the maintainer's setup)
- **Gitea MCP** server — for Gitea API access from inside pi
- **age**, **git-crypt** — encryption tooling
### SSH and networking
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
- A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
## Versioning
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc. `latest` always points at the most recent release. The pi binary is inherited from `joakimp/pi-devbox:base-pi-only`, so each release follows an opencode-devbox release that bakes the target pi version. (`base-pi-only` is an internal building-block tag — pull `latest` or a `vX.Y.Z` tag instead.)
From v1.0.0 onward, pi-devbox uses **semver**:
For container-level rebuilds on the same pi version (security updates, base bumps, fixes) the tag gets a letter suffix: `v0.74.0b`, `v0.74.0c`, …
- **Major** — architectural changes. v1.0.0 is the first decoupled release, where pi-devbox got its own self-contained build chain (previously it was a thin re-brand of opencode-devbox's `pi-only` variant).
- **Minor** — new image variants, significant base additions.
- **Patch** — pi version bumps, smaller fixes.
The pi binary version inside any given release is shown in this description (currently **`{{PI_VERSION}}`** for `:latest`) and asserted by smoke tests to match what's documented — version drift is caught at CI time, not on user pull.
> **Pre-v1.0.0 history.** Tags v0.74.0…v0.79.0 followed the pi npm version directly (`v{pi_version}[letter]`). Those images remain on Hub but are deprecated in favor of `:latest` / `:v1.X.Y`. The legacy `:base-pi-only*` tags were CI artifacts of the old opencode-devbox-based build pipeline; they will be removed in a future opencode-devbox v2.0.0.
### Build pipeline
pi-devbox is built in two phases:
1. **Base** (`Dockerfile.base`) → `base-<hash>` tag, content-addressed over `Dockerfile.base` + `rootfs/` + `entrypoint*.sh`. Rebuilt only when those change.
2. **Variant** (`Dockerfile.variant`) → `:latest` and `:vX.Y.Z`. FROMs the base, adds the pi install + companions.
`base-latest` is an alias of the most recent base.
## Persistent state
@@ -86,6 +133,7 @@ User edits and pi-installed packages survive container recreation when you mount
| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump database |
| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state |
| `devbox-uv` | `/home/developer/.local/share/uv` | uv Python installs and tool cache |
| `devbox-ssh-local` | `/home/developer/.ssh-local` | LAN-jump key (one-time host authorization survives recreate) |
Optional volumes for MemPalace (commented out by default — uncomment in `docker-compose.yml` to persist conversation memory across restarts):
@@ -101,10 +149,10 @@ Optional volumes for MemPalace (commented out by default — uncomment in `docke
## Source
- **This image**: https://gitea.jordbo.se/joakimp/pi-devbox
- **Base image**: https://gitea.jordbo.se/joakimp/opencode-devbox (Hub: `joakimp/opencode-devbox`)
- **pi**: https://github.com/earendil-works/pi
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
- **MemPalace**: https://github.com/MemPalace/mempalace
## License
+98 -3
View File
@@ -48,8 +48,14 @@ ENV DEBIAN_FRONTEND=noninteractive
# preview/export pipelines and broadly useful for any
# agent-driven document workflow. ~200 MB.
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
# See the bundled `dot-watch` helper for live .dot -> PNG
# re-render (handy with pi-studio's image preview).
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
# yq — YAML-aware companion to jq.
# socat — TCP relay. Powers `studio-expose`, which bridges
# pi-studio's container-loopback server to the container's
# external interface so a published port can reach it.
# ~1 MB; generally useful for any port-forwarding need.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
@@ -85,6 +91,7 @@ RUN apt-get update && \
pandoc \
graphviz \
imagemagick \
socat \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -123,6 +130,15 @@ RUN printf '%s\n' \
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired.
#
# CAVEAT (and why it is handled elsewhere): a user per-host override that
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
# failure — a system drop-in here can never override a user's per-host value.
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
# unwritable system ControlPath and falling back to its own /tmp master; for
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
#
# ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
@@ -272,21 +288,91 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
# Provides semantic search over conversation history via 29 MCP tools.
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
# time to shave ~300 MB.
#
# Stall protection (fixed 2026-06-13): mempalace-mcp is launched by the
# `mempalace.ts` pi extension from mempalace-toolkit (cloned below). That
# extension now applies a per-REQUEST timeout in its JSON-RPC client and
# kills the child on stall, so a virtiofs cold-open of chroma.sqlite3 /
# HNSW load can no longer hang the pi TUI uninterruptibly. Tunables:
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
# (default 120000); 0 disables. A standalone stdio-watchdog shim is NOT
# needed — the extension already owns request/response correlation. See
# CHANGELOG.md "Unreleased > Fixed".
ARG INSTALL_MEMPALACE=true
# Pin to a known-good version. Bump deliberately, not implicitly: an
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
# diary_write schema (see workaround RUN below + issue #1728). Pinning
# makes mempalace upgrades a reviewable diff rather than a surprise.
ARG MEMPALACE_VERSION=3.4.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache mempalace && \
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
# "either entry or content must be supplied". Anthropic's tools API rejects
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
# allOf, or anyOf at the top level`.
#
# Patch the advertised schema to require ["agent_name", "entry"] and remove
# the anyOf block. The handler keeps accepting `content` server-side as a
# kwarg alias so existing callers still work.
#
# Idempotent and self-deactivating: once upstream releases the fix the
# regex no longer matches (and the WARN below fires) — that's the signal
# to delete this RUN.
# Upstream status (last checked 2026-06-14):
# issue #1728 — STILL OPEN (root-level anyOf rejected by Anthropic/Codex)
# PR #1735 — CLOSED UNMERGED 2026-06-11; do NOT watch it (dead)
# PR #1717 — open; the current live fix candidate to watch
# mempalace PyPI latest = 3.4.0 (== our pin) → no release contains the fix yet
# https://github.com/MemPalace/mempalace/issues/1728
# https://github.com/MemPalace/mempalace/pull/1717
# TODO: remove this RUN once a mempalace release > 3.4.0 that actually strips
# the root-level anyOf ships on PyPI and is installed by the line above.
# Keep MEMPALACE_VERSION in lockstep with opencode-devbox when bumping.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
else \
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
fi ; \
fi
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
# overridable so a relocated/forked build can clone from a mirror or a
# different host without editing this Dockerfile (mirrors the
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
git -C /opt/mempalace-toolkit init -q && \
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
[ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
@@ -395,9 +481,18 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
# by entrypoint-user.sh, and the snippet is concatenated onto the global
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/bin/studio-expose \
/usr/local/bin/dot-watch \
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
# Start as root — entrypoint adjusts UID/GID then drops to developer
+162 -12
View File
@@ -41,6 +41,12 @@ ARG USER_NAME=developer
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
# relocated/forked build can clone from a mirror or a different host
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
# under elpapi42. CI resolves these to commit SHAs to defeat the same
# cache-hit footgun that affects PI_VERSION.
@@ -50,16 +56,16 @@ ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
ARG PI_OBSMEM_REF=master
RUN set -e && \
git_clone_retry() { \
url="$1"; ref="$2"; dest="$3"; \
for i in 1 2 3 4 5; do \
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
rm -rf "$dest"; \
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
# which (a) works with both name and SHA forms uniformly, and (b) defeats
# the registry-buildcache footgun when CI passes a resolved SHA. The
# earlier helper `git_clone_retry` (using `git clone --branch`) only
# worked with branch names — a SHA-resolved build-arg made `git clone
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
git_fetch_ref() { \
url="$1"; ref="$2"; dest="$3"; \
rm -rf "$dest"; mkdir -p "$dest"; \
@@ -77,8 +83,8 @@ RUN set -e && \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
@@ -88,6 +94,94 @@ RUN set -e && \
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
# the canonical skill under skill/, so here — after the pinned clone — we copy
# that over the snapshot. Result: a normal build ships the fresh, package-owned
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
# ref predates the skill, or a fork pointing at a mirror without it, still ships
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
cp /opt/pi-extensions/skill/SKILL.md \
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
fi && \
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
else \
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
fi
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
# every container start. There is no second global slot, and that file is
# root-owned (not writable by the runtime user), so we compose at BUILD time:
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
# makes every container proactively aware of the pi-devbox-environment skill;
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
else \
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
fi
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
# preview, and tmux-backed literate REPLs. Off by default; the studio
# variant sets INSTALL_STUDIO=true.
#
# Vendored to /opt/pi-studio and registered at container start by
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
# which is a named volume, so a build-time install collides with / is
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
# + a runtime local-path install keeps it on the image and idempotent.
#
# No build step is needed: pi-studio ships its browser bundle prebuilt in
# git (client/studio-client.js) and pi loads index.ts directly; its
# package.json scripts are only test/typecheck. So we just fetch + install
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
#
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
# buildcache cache-hit footgun (see the PI_VERSION note above).
ARG INSTALL_STUDIO=false
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
ARG PI_STUDIO_REF=main
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
set -e; \
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
git -C /opt/pi-studio init -q && \
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
[ "$ok" = "1" ] && \
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
fi
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
# so reaching it from a browser needs a loopback bridge or host networking —
# see the "Using pi-studio" section in README.md.
ENV STUDIO_PORT=8765
# ── Optional: Go toolchain ───────────────────────────────────────────
# Off by default; opt in for users who run Go tools inside the devbox.
ARG INSTALL_GO=false
@@ -106,4 +200,60 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
fi
# ── Build provenance: OCI labels + on-disk build manifest ────────────
# Records exactly which pi version and companion-repo commits were baked
# into THIS image, so a published tag is self-describing and reproducible
# after the fact (CI logs rotate; a released image must not depend on
# them). Previously the resolved SHAs only ever reached the CI build log.
#
# These ARGs are declared LAST, immediately before the layer that uses
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
# invalidates the expensive pi-install / clone layers above.
ARG RELEASE_TAG=dev
ARG BUILD_DATE=
ARG SOURCE_REVISION=
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
# only so its intended ref lands in the label set alongside the others.
ARG MEMPALACE_TOOLKIT_REF=main
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}"
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
# of each /opt clone and the live `pi --version` — not merely the intended
# build-args. That way it also exposes a clone that silently resolved to
# something other than the requested ref. pi-studio is present only in the
# studio variant (JSON null otherwise).
RUN set -e; \
mkdir -p /etc/pi-devbox; \
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
STUDIO_REV='null'; \
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
{ \
echo '{'; \
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
echo " \"build_date\": \"${BUILD_DATE}\","; \
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
echo " \"pi_version\": \"${PI_V}\","; \
echo " \"components\": {"; \
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
echo " \"pi-studio\": ${STUDIO_REV}"; \
echo " }"; \
echo '}'; \
} > /etc/pi-devbox/build-manifest.json; \
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+389 -38
View File
@@ -82,6 +82,9 @@ For Python REPLs and notebooks beyond the system interpreter, see the
- A LAN-access helper that auto-configures ssh jump-via-host on
VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container
can reach the host's directly-attached LAN peers.
- Read-only `~/.ssh` is handled transparently: a per-host `ControlPath`
under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a
writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
## Quickstart
@@ -98,7 +101,7 @@ git clone https://gitea.jordbo.se/joakimp/pi-devbox
cd pi-devbox
cp .env.example .env # edit if needed
docker compose up -d
docker compose exec devbox bash
docker compose exec -u developer devbox bash
```
You're now in the container as user `developer` with `pi` on PATH and
@@ -131,16 +134,171 @@ Currently published:
|---|---|---|
| `joakimp/pi-devbox:latest` | base + pi + tooling | ~3.2 GB |
| `joakimp/pi-devbox:vX.Y.Z` | pinned-version equivalent | ~3.2 GB |
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
Planned for upcoming minor releases:
Planned for an upcoming minor release:
- `joakimp/pi-devbox:latest-studio`adds [pi-studio](https://github.com/omaclaren/pi-studio)
for browser-based prompt editing, KaTeX/Mermaid preview, and
literate REPLs (Shell / Python / IPython / Julia / R / GHCi /
Clojure). Adds ~50 MB.
- `joakimp/pi-devbox:latest-studio-tex` — also adds `texlive-xetex`
- `joakimp/pi-devbox:latest-studio-tex``-studio` plus `texlive-xetex`
for PDF export from Studio. Adds ~600 MB on top of `-studio`.
## Using pi-studio (`-studio` variant)
The `-studio` images bundle [pi-studio](https://github.com/omaclaren/pi-studio):
a two-pane browser workspace with a prompt/response editor, live
KaTeX/Mermaid preview, and tmux-backed literate REPLs (Shell / Python /
IPython / Julia / R / GHCi / Clojure). It is registered automatically on
container start (no `pi install` needed) and exposes the `/studio` slash
command plus the `studio_repl_send` / `studio_export_*` agent tools.
Inside a pi session in the container:
```
/studio --no-browser --port 8765 # pin a fixed port; STUDIO_PORT=8765 is the baked default
/studio --status # reprint the tokenized URL
```
### Reaching the UI from your browser (the container caveat)
pi-studio **hard-binds its server to `127.0.0.1` inside the container**
(`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL.
There is no `--host`/bind flag. This matters for a container: a plain
`docker run -p 8765:8765` publish forwards to the container's *external*
interface, **not** its loopback, so it will not reach Studio. Two paths
work:
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
Run the container with host networking so the container's loopback is the
host's loopback:
```yaml
services:
devbox:
network_mode: host # container 127.0.0.1 == host 127.0.0.1
```
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
host. This is the most secure option (Studio never leaves loopback). Note:
host networking changes `host.docker.internal` semantics, so weigh it
against the LAN-jump SSH feature if you use that.
**B. `studio-expose` bridge (portable — any networking mode).** Publish a
port and run the bundled `studio-expose` helper, which uses `socat` to
bridge the container's loopback to its external interface (binding the
egress IP on the same port, so the token URL Studio printed works
verbatim):
```yaml
services:
devbox:
ports:
- "127.0.0.1:8765:8765" # host-localhost only
environment:
- STUDIO_EXPOSE=1 # auto-start the bridge on container boot
```
With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run
`/studio --port 8765` in your pi session. To bridge manually instead
(leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell:
```bash
studio-expose & # bridges $STUDIO_PORT (default 8765); --help for details
```
> **`studio-expose` runs in the foreground** (it's a `socat` relay) — it
> blocks the shell until Ctrl-C. Background it with `&` or run it in its
> own tmux pane. It only relays traffic; it does **not** print a token.
> The lines it prints ending in `...token=...` are literal help text, not
> a truncated URL — the real token comes from `/studio` (see below).
> **Security:** the bridge intentionally exposes Studio beyond loopback;
> its tokenized URL is the only auth. Keep the host-side publish on
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
### Remote host (SSH / mosh)
When the Docker host is remote, keep Studio on localhost and forward the
port from your laptop:
```bash
ssh -L 8765:127.0.0.1:8765 user@docker-host # then open the token URL locally
```
**mosh cannot forward ports** (no `-L`/`-R` equivalent). To use Studio
over a mosh session, run a *separate* `ssh -L 8765:127.0.0.1:8765 host`
tunnel alongside mosh (mosh for the shell, ssh for the port), or reach the
host's published port directly over a trusted network (LAN / Tailscale /
WireGuard).
#### End-to-end recipe: remote host, mosh shell, `studio-expose` bridge
The full path has four network hops, each added by one step:
```mermaid
flowchart LR
browser["laptop browser"]
host["host :8765"]
eth0["container eth0 :8765"]
loop["container 127.0.0.1 :8765"]
studio["pi-studio"]
browser -->|"ssh -L"| host
host -->|"docker -p"| eth0
eth0 -->|"studio-expose (socat)"| loop
studio -->|"binds"| loop
```
Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B):
1. **In a container shell** — start the bridge (skip if `STUDIO_EXPOSE=1`
is set in compose, which auto-starts it):
```bash
studio-expose &
```
2. **In your pi session** (the pi TUI in the container) — start Studio and
print the tokenized URL. `/studio` is a slash command you type in the
TUI, not a shell command:
```
/studio --no-browser --port 8765
/studio --status # reprint the URL anytime
```
Copy the `http://…:8765/?token=<token>` it prints. **This** is where
the real token comes from — not `studio-expose`.
3. **On your laptop** — open the ssh port-forward alongside mosh:
```bash
ssh -L 8765:127.0.0.1:8765 user@docker-host
```
4. **In your laptop browser** — open `http://127.0.0.1:8765/?token=<token>`
(keep the port and token verbatim; only the host part is `127.0.0.1`).
> **Order check:** nothing listens on the container's `127.0.0.1:8765`
> until step 2 runs. If the browser can't connect, verify Studio is up
> (`/studio --status`) and the bridge is running (`ps aux | grep socat`).
> PDF export (`/studio-pdf`, `studio_export_pdf`) needs a LaTeX engine,
> which is **not** in `-studio` (only the planned `-studio-tex`). HTML
> export, KaTeX, Mermaid, and all REPL features work without it.
### Graphviz diagrams in Studio: `dot-watch`
pi-studio renders **Mermaid** natively but has **no Graphviz/DOT renderer**.
Its markdown preview *does* render local image links (`.png`/`.jpg`/`.gif`/
`.webp`), so the workflow for Graphviz is: write a `.dot` file, render it to
PNG with `dot`, and preview the PNG (directly, or embedded in a markdown
file). The bundled **`dot-watch`** helper automates the re-render so edits
show up on Studio's *refresh-from-disk*:
```bash
dot-watch graph.dot # dot engine, 150 dpi -> graph.png
dot-watch graph.dot neato 200 # pick layout engine + dpi
```
It polls the file's mtime (no `inotify` dependency) and regenerates
`<name>.png` on every save, printing timestamped status and indenting any
DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
`.md` that embeds it) and hit **refresh-from-disk** after each edit.
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
## docker-compose.yml — basic shape
```yaml
@@ -152,37 +310,50 @@ services:
container_name: pi-devbox
stdin_open: true
tty: true
env_file: .env
# pi-studio (only on `-studio` images): publish loopback + enable the
# socat bridge so the browser UI is reachable. See "Using pi-studio".
# ports:
# - "127.0.0.1:8765:8765" # host-localhost only; use ssh -L for remote
env_file:
- .env
environment:
- TZ=${TZ:-Europe/Stockholm}
- TERM=xterm-256color
- AWS_PROFILE=${AWS_PROFILE:-}
- AWS_REGION=${AWS_REGION:-eu-west-1}
# - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
volumes:
# Workspace: your host source tree, read-write
- ${HOST_WORKSPACE:-./workspace}:/workspace:rw
# Workspace: your host source tree
- ${WORKSPACE_PATH:-.}:/workspace
# SSH keys: read-only from host
- ${HOME}/.ssh:/home/developer/.ssh:ro
# AWS config: read-only from host
- ${HOME}/.aws:/home/developer/.aws:ro
# MemPalace: bind-mounted so host pi and container pi share a brain
- ${HOME}/.mempalace:/home/developer/.mempalace:rw
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Per-container persistent state
- devbox-pi-config:/home/developer/.pi
- devbox-bash-history:/home/developer/.cache/bash
- devbox-ssh-local:/home/developer/.ssh-local
- devbox-shell-history:/home/developer/.cache/bash
- devbox-zoxide:/home/developer/.local/share/zoxide
- devbox-nvim-data:/home/developer/.local/share/nvim
- devbox-uv-tools:/opt/uv-tools
- devbox-chroma-cache:/home/developer/.cache/chroma
- devbox-uv:/home/developer/.local/share/uv
# Optional (uncomment to enable):
# - ~/.aws:/home/developer/.aws # AWS creds
# - devbox-palace:/home/developer/.mempalace # persist palace
# - devbox-chroma-cache:/home/developer/.cache/chroma # embedding cache
volumes:
devbox-pi-config:
devbox-bash-history:
devbox-ssh-local:
devbox-shell-history:
devbox-zoxide:
devbox-nvim-data:
devbox-uv-tools:
devbox-chroma-cache:
devbox-uv:
# devbox-palace:
# devbox-chroma-cache:
```
See `.env.example` in the repo for available environment variables.
See `docker-compose.yml` and `.env.example` in the repo for the full
template (build-from-source args, LAN-jump and skillset mounts, MemPalace
persistence). To share one palace between host pi and the container,
bind-mount your host `~/.mempalace` to `/home/developer/.mempalace`.
## uv-driven REPL recipes
@@ -238,15 +409,16 @@ to refresh.
| Path inside container | Volume | What survives |
|---|---|---|
| `/workspace` | host bind-mount | host filesystem |
| `~/.ssh` | host bind-mount (read-only) | host filesystem |
| `~/.aws` | host bind-mount (read-only) | host filesystem |
| `~/.mempalace` | host bind-mount | host filesystem |
| `/workspace` | host bind-mount (`WORKSPACE_PATH`) | host filesystem |
| `~/.ssh` | host bind-mount (read-only, `SSH_KEY_PATH`) | host filesystem |
| `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes |
| `~/.cache/bash` | named volume | `down -v` wipes |
| `~/.local/share/nvim` | named volume | `down -v` wipes |
| `/opt/uv-tools` | named volume | `down -v` wipes |
| `~/.cache/chroma` | named volume | `down -v` wipes |
| `~/.ssh-local` | named volume `devbox-ssh-local` | `down -v` wipes |
| `~/.cache/bash` | named volume `devbox-shell-history` | `down -v` wipes |
| `~/.local/share/zoxide` | named volume `devbox-zoxide` | `down -v` wipes |
| `~/.local/share/nvim` | named volume `devbox-nvim-data` | `down -v` wipes |
| `~/.local/share/uv` | named volume `devbox-uv` | `down -v` wipes |
| `~/.mempalace` | host bind-mount or `devbox-palace` (optional) | host / volume |
| `~/.cache/chroma` | `devbox-chroma-cache` (optional) | `down -v` wipes |
Anything not on a volume is on the writable layer and is lost on
container recreate.
@@ -270,6 +442,56 @@ session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
diary-write, etc.) are wired into pi automatically by the pi-extensions
mempalace bridge.
## Agent skills
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
directory, and they compose:
- **Image-baked skills (always present).** Skills shipped *inside* the image
live under `/usr/local/share/pi-devbox/skills/` and are symlinked into
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
external mount, survive volume recreate (the source is an image path, not a
home dir a named volume would shadow), and are created only when absent so a
same-named skillset skill or user override is never clobbered. The bundled
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
the container's persistence model, host/LAN SSH reachability, split-DNS
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
*mechanisms* (deployment-specific hostnames/domains/nameservers are
discovered at runtime, never hardcoded).
- **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
fork/recall under-utilisation). That pointer would dangle in a container
started *without* the private `skillset` repo, so the image also bakes
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
symlinked only when absent, so a mounted skillset always overrides them. The
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
build, so a normal build ships the fresh copy and an old-ref/mirror build
still ships the snapshot. `mempalace` is snapshot-only (its consumer skill
has no public package home), and because pi-toolkit's `AGENTS.md` has no
directive for it, the pi-devbox managed block adds a session-start
*proactive-load* pointer for it (gated to pi-devbox containers, conditional
on the MemPalace MCP tools) so a new container actually loads it. See
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
classified as foreign-links by its `--prune-stale` pass and left untouched.
To make agents *proactively* load a baked skill at session start (rather than
only on description match), the image appends a short, gated pointer to the
global `AGENTS.md` at build time (see `pi-global-AGENTS.append.md`). The
pointer fires only inside a pi-devbox container (it checks for
`/usr/local/lib/pi-devbox/`).
To add another image-baked skill: drop a `SKILL.md` under
`rootfs/usr/local/share/pi-devbox/skills/<name>/`; the `COPY` in
`Dockerfile.base` and the entrypoint symlink loop pick it up automatically. To
refresh a vendored fallback, see
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
## SSH and ControlMaster
The base image preconfigures `Host *` ssh defaults:
@@ -292,6 +514,27 @@ User-level overrides in `~/.ssh/config` win because Debian's
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
the `Host *` block.
### Per-host `ControlPath` on a read-only `~/.ssh`
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
points `ControlPath` back under it (e.g. the CGNAT idiom
`ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a
system default can never override a user's per-host value. Two layers handle
this without editing the read-only config:
- **`pi --ssh <host>`** — the `ssh-controlmaster` extension detects an
unwritable system `ControlPath` and falls back to its own writable
`/tmp/pi-cm-<pid>.sock` master (its command-line `-o ControlPath` overrides
the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it
cannot fail on the read-only socket dir.
- **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh`
redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host
(the sidecar is rendered on all host OSes). To name LAN peers that should
jump via the host, add `ProxyJump host` overrides in the host-owned
`~/.config/devbox-shell/ssh-lan.conf` (see
[Naming LAN peers](#naming-lan-peers)) rather than the read-only
`~/.ssh/config`.
## tmux and 0-indexed sessions
The image installs `/etc/tmux.conf` with:
@@ -302,9 +545,9 @@ set -g pane-base-index 0
```
This is the default tmux indexing. It's baked here because `pi-studio`
(planned for `:latest-studio`) hard-codes its tmux send target to
`<session>:0.0`. If you override `base-index` to 1 in a personal
`~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
(shipped in the `:latest-studio` variant) hard-codes its tmux send
target to `<session>:0.0`. If you override `base-index` to 1 in a
personal `~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
## AWS Bedrock auth
@@ -327,8 +570,11 @@ pi-devbox is built from this repo's CI in two phases:
where `<hash>` is content-addressed over `Dockerfile.base`,
`rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change.
2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds
the pi install. The `:latest` and `vX.Y.Z` tags are produced from
this layer; future Studio variants will extend further.
the pi install (+ pi-studio when `INSTALL_STUDIO=true`). The `:latest`
/ `vX.Y.Z` and `:latest-studio` / `vX.Y.Z-studio` tags are produced
from this layer. The studio variant builds via independent
`smoke-studio` + `build-variant-studio` CI jobs that gate only the
`-studio` tags.
Tag naming:
@@ -337,6 +583,7 @@ Tag naming:
| `base-<hash>` | base image — internal building block |
| `base-latest` | promoted alias of the most recent base |
| `latest`, `vX.Y.Z` | variant: base + pi |
| `latest-studio`, `vX.Y.Z-studio` | variant: base + pi + pi-studio |
CI resolves `PI_VERSION` to a concrete version string before building
to defeat a registry-buildcache hit on `npm install -g
@@ -344,6 +591,68 @@ pi-coding-agent@latest` (the build-arg string would otherwise be
byte-identical across releases and the layer would silently reuse the
previous version's bytes).
### Building a fork / relocated build
The canonical build clones its companions from `gitea.jordbo.se`. Every
companion repo URL is an overridable build-arg (defaulting to the canonical
origin), so a fork or a build on a host that can't reach that gitea can
repoint each one at a mirror, another host, or a local `file://` path
**without editing the Dockerfiles**:
| Build-arg | Default | Dockerfile |
|---|---|---|
| `PI_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/pi-toolkit.git` | variant |
| `PI_EXTENSIONS_REPO` | `https://gitea.jordbo.se/joakimp/pi-extensions.git` | variant |
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant |
| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant |
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
the variant against forked toolkit/extensions and a pinned pi:
```bash
# base first (mempalace-toolkit lives here)
docker build -f Dockerfile.base -t myorg/pi-devbox:base-dev \
--build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git .
# then the variant FROM that base
docker build -f Dockerfile.variant -t myorg/pi-devbox:dev \
--build-arg BASE_IMAGE=myorg/pi-devbox:base-dev \
--build-arg PI_VERSION=0.79.7 \
--build-arg PI_TOOLKIT_REPO=https://github.com/myorg/pi-toolkit.git \
--build-arg PI_EXTENSIONS_REPO=https://github.com/myorg/pi-extensions.git .
```
Note: the gitea companions clone anonymously (no token needed); only the
`resolve-versions` CI job calls the gitea *API* (which needs a token even
for public repos). A plain `docker build` like the above skips that job
entirely, so no credentials are required for a local/forked build.
Provenance build-args (all optional; populate the OCI labels and
`/etc/pi-devbox/build-manifest.json` — see below): `RELEASE_TAG`,
`BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build
leaves them at harmless defaults.
### Build provenance (labels + manifest)
Every published image is self-describing. Inspect the OCI labels without
pulling the filesystem:
```bash
docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq .
```
`org.opencontainers.image.{version,revision,created}` plus
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
truth** — the actual checked-out commit of each `/opt` clone and the live
`pi --version` — so a tag is reconstructable after CI logs rotate:
```bash
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
```
## Troubleshooting
### Image grew unexpectedly
@@ -360,12 +669,54 @@ auto-runs on container start and writes `~/.ssh-local/config` with a
ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
`HOST_SSH_USER=<your-mac-user>` in `.env` if auto-detection fails.
#### Naming LAN peers
`DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` only set up the *jump* to the host. To
make a **named** peer route through it — so `pi --ssh alpserv-2`,
`dssh alpserv-2`, etc. resolve the ProxyJump — add a `ProxyJump host` override
for it in the host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf`
(**not** `~/.ssh/config`, which is mounted read-only):
```
Host pve pve-2 alpserv-2 lagret
ProxyJump host
```
`HostName` / `User` / `IdentityFile` are inherited from the matching block in
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every
connection — newly added peers work immediately, no container or session
restart needed — and the peer names stay out of the published image (they're a
fact about your specific LAN, not the image). Alternatively, set
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the
host without naming peers (see `.env.example`).
### Smoke-testing a local build
```bash
./scripts/smoke-test.sh joakimp/pi-devbox:latest
```
`smoke-test.sh` is a **build-time** check (runs with `--entrypoint=""`), so
it validates image contents and a fresh entrypoint deploy — it never sees a
recreated container's persisted volumes.
### Post-recreate sanity check
After `docker compose up -d --force-recreate`, run the **runtime** peer of
`smoke-test.sh` from *inside* the container to confirm the new image is live,
persisted volumes survived, and pi runtime wiring is intact:
```bash
./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-version 0.79.4 # assert pi version
```
If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same
check by short name and locates the repo automatically (override with
`PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is
maintainer tooling and is **not** shipped in the published image.
## Versioning and release
pi-devbox follows semver-ish:
+302
View File
@@ -0,0 +1,302 @@
# Design: single-writer MemPalace broker (cross-host serialization)
> **Status:** DRAFT / RFC — not yet implemented. Captures the design so it can be
> picked up later. Authored 2026-06-14.
> **Owner:** unassigned. **Tracking:** queue item #4 ("host-side mempalace-mcp
> daemon over a UNIX/shared socket").
## Problem
The pi-devbox container's `~/.mempalace` (`/home/developer/.mempalace`) is a
**virtiofs bind-mount of the host's `/Users/joakim/.mempalace`** (verified
2026-06-14 via `/proc/mounts`: `mac /home/developer/.mempalace virtiofs rw`).
Container pi and host-native pi therefore **read and write ONE shared palace**
full memory parity already exists; nothing needs to be built to *enable* sharing.
The actual hazard is the opposite of sharing: **concurrency**. Two pi processes
(one native on the host, one in the container) can open the same
`chroma.sqlite3` / `knowledge_graph.sqlite3` and write at the same time. The
palace directory already shows the scars of this:
- `chroma.sqlite3.broken-20260505`
- many `*.corrupt-20260528`
- a long run of `*.drift-2026*`
- `locks/` with `mine_palace_*.lock` files, including a **stale** one.
These are mempalace's defensive lock + auto-snapshot/repair machinery firing
under concurrent access.
### Why a shared lock file is NOT sufficient
The container runs inside a Linux VM (OrbStack / Docker Desktop on macOS); the
palace bytes live on the macOS host, surfaced into the VM via virtiofs.
Consequences:
- A **UNIX-domain socket file** visible at `~/.mempalace/broker.sock` inside the
container is a *host-kernel* object. The container's kernel can see the inode
but **cannot connect to it** across the VM boundary.
- **flock / advisory lockfiles are not coherent across the host↔VM boundary.**
A lock taken on the host is not reliably seen in the container and vice-versa.
(The stale `mine_palace_*.lock` is direct evidence the existing lock scheme is
not bulletproof across this boundary.)
**Therefore the only trustworthy serialization is to route every write through a
single process.** That single process is the broker. The design question is *not*
"how do we lock" — it's "**where does the one writer live, and how does every pi
(host or container) reach it across the VM boundary?**"
## Goals
1. Exactly one process opens the palace SQLite files at any time (single writer;
concurrent reads are fine).
2. Works in all three topologies on a given host:
- native pi only,
- native pi + container pi,
- container pi only.
3. pi configuration is **identical** in every topology (no per-environment MCP
config divergence).
4. No new corruption pathway introduced; degrade safely when the broker is
genuinely unreachable and there are no peers.
### Non-goals (for this iteration)
- opencode / opencode-devbox co-existence (see "Co-existence with opencode"
below — deferred until the pi case is solved).
- Multi-host palace replication. This is about one host's local palace.
- Changing mempalace's on-disk format or its public MCP tool surface.
## Architecture
```
pi (host) ─stdio─► mp-shim ─┐
├─► mempalace-broker ─► chroma.sqlite3
pi (ctr) ─stdio─► mp-shim ─┘ (SINGLE owner; knowledge_graph.sqlite3
serialized writer, + in-memory HNSW index
concurrent readers)
```
### `mempalace-broker`
A long-lived process that is the **only** opener of the palace SQLite files. It:
- runs the real mempalace engine,
- holds the HNSW index in memory,
- pushes all mutations through a single writer queue (reads may fan out),
- exposes the mempalace MCP JSON-RPC surface over one or more transports,
- is the canonical owner of palace state for the lifetime of the host session.
**Bonus:** a single always-resident owner also eliminates the stale-HNSW-index
problem that `mempalace_reconnect` exists to work around — there is never an
external writer to desync the in-memory index against.
### `mp-shim`
A tiny stdio↔transport adapter. pi's mempalace MCP config points at the shim
**everywhere, unchanged**. pi still believes it is speaking stdio MCP to a local
server; the shim forwards JSON-RPC to the broker over whichever transport is
available, and handles all discovery / startup / election complexity. Keeping
pi's config identical across topologies is a hard requirement (goal #3) and the
shim is what makes it possible.
## Canonical owner = the host
The broker's home is **always the host**, because:
1. The palace bytes physically live there (`/Users/joakim/.mempalace`).
2. The host outlives any container — ownership does not evaporate on
`docker compose down`.
3. Containers already have a route back to it (`host.docker.internal` and the
verified dssh ControlMaster bridge).
The broker binds **two listeners feeding one queue**:
- **AF_UNIX** at `$MEMPALACE_PATH/broker.sock` — for host-native pi (fast,
filesystem-perms-secured).
- a **cross-boundary** transport for container clients (below).
## Transport matrix
| Topology | Broker runs on | Host pi reaches it via | Container pi reaches it via |
|---|---|---|---|
| native only | host | AF_UNIX socket | — |
| native + container | host | AF_UNIX socket | SSH-forwarded socket (preferred) or TCP |
| container only | host (started via bridge) | — | SSH-forwarded socket or TCP |
### Cross-boundary transport options
**(a) SSH-forwarded UNIX socket over the existing dssh ControlMaster — PREFERRED.**
The container's `setup-lan-access.sh` already establishes a ControlMaster to the
host with `ControlPersist 4h`. The container shim forwards the host broker socket
over that master:
```
ssh -F ~/.ssh-local/config \
-L "$XDG_RUNTIME_DIR/mp.sock:$HOME/.mempalace/broker.sock" host
```
then connects to the local forwarded socket. Auth = SSH key; nothing is
LAN-exposed; no extra shared secret needed; rides the persistent master so setup
cost is near-zero. Most portable across non-OrbStack hosts.
**(b) TCP on `host.docker.internal:PORT` — fallback.** Simpler, but the broker
must bind a routable interface (not just `127.0.0.1`), which requires a
**shared-secret token** to prevent other local/LAN processes from talking to it.
The token is written to `broker.json` in the virtiofs-mounted palace dir
(readable from both sides). More care required to get the bind + auth right.
## Discovery + on-demand start (the shim's algorithm)
Run by the shim on every pi session start, so it is correct regardless of who is
already running:
```
1. If $MEMPALACE_BROKER is set → use it verbatim (escape hatch).
2. Read $MEMPALACE_PATH/broker.json → endpoint + pid + token.
Try to connect (UNIX if host; forwarded-sock / TCP if container).
If connected & healthy → done.
3. Broker not reachable → START IT:
- On host: flock($MEMPALACE_PATH/broker.lock, non-blocking)
win → exec broker, wait for broker.json, connect.
lose → someone else is starting it; backoff + retry connect.
- In container: run `ssh host 'mempalace-broker --ensure'` (idempotent;
performs the SAME flock election ON THE HOST), then forward +
connect.
4. Last-resort fallback (no broker, cannot start one):
open the palace DIRECTLY — but ONLY after asserting this process is the sole
writer (no other live broker/pid recorded in broker.json). Degrades to
today's behaviour for the genuinely-alone case; never used when a broker
exists.
```
**Key trick:** host-side election uses `flock` on the host, where it is coherent
(same kernel) — bulletproof. The cross-boundary case **never relies on cross-VM
locking**; it relies on `ssh host 'broker --ensure'`, which runs the election on
the host where flock works. That is what makes the design topology-independent.
### Lifecycle
- Broker writes `broker.json` (endpoint + pid + token) **atomically** after
binding.
- Broker holds `broker.lock` for its entire lifetime → at most one host broker.
- Idle-exit after N minutes with no connected clients; the next client
re-elects. (Or keep-alive; idle-exit is friendlier on resources.)
- Clients reclaim a stale lock if the pid recorded in `broker.json` is dead.
- Clients retry with backoff while a broker is mid-startup.
## Engine vs. shim — what the image must still ship
The component bundled in the images today is really **two separable pieces**:
- the **mempalace engine** — opens the SQLite files, computes embeddings, owns
the HNSW index (the heavy part: chromadb, embedding model, etc.), and
- the thin client surface pi actually talks to.
In the brokered design these split cleanly:
- the **broker** is the only thing that runs the *engine*;
- the **shim** is **engine-free** — it just forwards MCP JSON-RPC. It needs no
chromadb, no embedding model, no heavy deps. Embeddings/search happen
broker-side. (Potential image-slimming opportunity, though see below for why
we keep the engine bundled anyway.)
Whether the bundled engine is "used as-is" or merely fronted by the broker
**depends on who owns the broker**:
**A) Host runs the broker (native, or native+container — the common case).**
The *host's* engine is authoritative and used as-is. The broker is purely an
intermediate step so writes can't collide; the host engine does the read/write.
The container's **bundled engine is dormant** — the container uses only its shim
to reach the host broker. The engine in the image is not needed for this path.
**B) Container lands on a host with no mempalace (fresh-host case).**
The bundled engine earns its keep — you cannot conjure an engine onto the host
without installing one. Either the container runs the broker *itself*
(in-container ownership, bundled engine used as-is) or it falls back to degraded
direct mode (single writer, bundled engine used directly).
**Decision: keep shipping the engine in the images** — but for three specific
reasons, not because the brokered path needs it:
1. **Self-containedness** — pi-devbox's promise is "works on any host." A
container with no memory unless the host pre-installed mempalace breaks that,
especially for the Docker Hub audience.
2. **Fresh-host bootstrap** (case B) — no host engine to borrow.
3. **Degraded fallback** — the no-broker-reachable path opens the DB locally and
needs the engine present.
In the host-managed common case the bundled engine is just dormant insurance;
the shim is the only piece the container actively uses.
### Version-coherence note
Because **only the broker's engine ever writes**, its version defines the
on-disk format. Host-vs-bundled engine version skew is therefore **harmless in
the brokered path** (only one engine ever touches the bytes). Skew only bites in
**degraded direct mode**, where the container writes with a possibly-different
engine version than the host would. This argues for the broker pinning/owning
the authoritative engine version and treating the bundled engine as
fallback-only.
> Partially resolves the "where the broker binary ships" open question below:
> the **shim** must ship on both sides; the **engine** must ship on the host
> (to run the broker) and stays bundled in the image as fallback/bootstrap
> insurance, not as the authoritative writer in the common case.
## The genuinely hard case
**Container-only with no SSH bridge configured** (e.g. plain Linux Docker,
`HOST_SSH_USER` unset, no `host.docker.internal`). The container cannot start or
reach a host broker. Options, none free:
1. **Require the bridge** for multi-writer container setups, and document it as a
precondition. Reasonable: pi-devbox already ships `setup-lan-access.sh` and
the bridge is the supported path.
2. **Run the broker inside the container**, publishing a Docker port the host can
later reach. Works, but inverts ownership and the broker dies with the
container — only acceptable if containers are the *sole* writers on that host.
3. **Accept degraded mode** (algorithm step 4): a lone container with no peers
has no concurrency, so direct access is safe *as long as* nothing else opens
the palace concurrently. The host shim also checks `broker.json` before
opening directly, so a later host pi will not silently start a second
uncoordinated writer.
**Summary:** fully robust for native-only, native+container, and
container-only-with-bridge. The only residual sharp edge is container-only
*without* a bridge *and* a future concurrent host writer — intrinsic (no shared
coherent lock exists across that boundary), best handled by mandating the bridge
rather than pretending file locks work.
## Co-existence with opencode / opencode-devbox (DEFERRED — context only)
The palace is shared by more than pi. opencode (native) and opencode-devbox
(container) also write to the same `~/.mempalace`. **Assumption to verify:**
opencode sessions write to **different wings** than pi sessions (pi uses
`wing_pi`, diaries per-agent, etc.), so cross-tool intermixing into the *same*
destination may be a non-issue at the application level.
However, the corruption risk here is at the **SQLite-file level, not the wing
level** — two processes writing different wings of the *same* `chroma.sqlite3`
concurrently is still a concurrent write to one file. So the broker, once it
exists, is the right serialization point for opencode too: opencode's mempalace
client would route through the same broker via the same shim mechanism.
**Decision:** do not design for opencode co-existence yet. Resolve the pi case
first; then revisit whether opencode clients adopt the same shim. The residual
risk in the interim is native + container *opencode* sessions writing the same
palace simultaneously — explicitly deferred ("cross that bridge later").
## Open questions / TODO before implementation
- Does the mempalace engine expose an embeddable entrypoint suitable for running
inside a long-lived broker, or does the broker wrap the existing MCP server
binary and multiplex stdio clients onto it? (Affects whether reads can truly
fan out or are also serialized.)
- Idle-exit timeout default + whether to expose it via env.
- `broker.json` schema + atomic-write + stale-pid-reclaim details.
- TCP-path token handling and safe bind interface selection on Linux Docker
(`--add-host=host.docker.internal:host-gateway`).
- Where the broker binary ships: baked into `Dockerfile.base`? host install via
pi-toolkit / mempalace-toolkit? Both, since both sides need the shim and the
host needs the broker.
- Smoke-test plan: prove single-writer invariant under a deliberate concurrent
host+container write storm (should produce zero `.corrupt`/`.drift` snapshots).
+97 -19
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default; this generates a
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# SSH-jump-via-host block so the container can reach the host's
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
# jump block is omitted but the sidecar is still rendered. Controlled by
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
# script header.
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
fi
@@ -36,6 +40,32 @@ if [ -d "$SKEL_DIR" ]; then
done
fi
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
# made available regardless of whether a skillset repo is mounted. Done EARLY
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
# the time anything gates on "container ready": the smoke-test readiness probe
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
# AFTER this point, so linking here closes a sample-too-early race that failed
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
# keeps the skill fresh from the image and surviving volume recreate (unlike
# anything baked under a home dir, which a named volume would shadow). Created
# only when absent, so a same-named skillset skill (deployed later, at the end
# of this script) or a user override is never clobbered; the skillset deploy
# classifies these as foreign-links and its --prune-stale pass leaves them
# alone (only dangling symlinks are pruned).
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
mkdir -p "$HOME/.agents/skills"
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve
@@ -86,9 +116,35 @@ if command -v pi &>/dev/null; then
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
[ -f /opt/pi-toolkit/settings.example.json ]; then
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
_pi_settings="$HOME/.pi/agent/settings.json"
_pi_template=/opt/pi-toolkit/settings.example.json
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
cp "$_pi_template" "$_pi_settings"
echo "pi settings.json bootstrapped from template"
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
# Non-destructive merge: a settings.json on a PRESERVED volume never
# otherwise sees new template keys (the bootstrap above only fires when
# the file is absent), so config added in an image upgrade — e.g. the
# observational-memory / pi-fork blocks or a newly-enabled model — never
# reaches existing users. Deep-merge with the template FIRST and the
# live file SECOND ('.[0] * .[1]') so the user's values always win and
# only keys MISSING from the live file are filled in from the template.
# Arrays are treated as leaves (the user's array is kept verbatim, so a
# model they deliberately removed is not re-added). Only rewrite when the
# merge actually changes something, and back up the original first.
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
# never clobber.
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
if [ -n "$_pi_merged" ] && \
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
printf '%s\n' "$_pi_merged" > "$_pi_settings"
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
fi
else
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
fi
fi
# pi↔mempalace MCP bridge — single extension symlink.
@@ -99,16 +155,19 @@ if command -v pi &>/dev/null; then
"$HOME/.pi/agent/extensions/mempalace.ts"
fi
# pi-fork (fork tool) + pi-observational-memory (recall tool).
# These are pi packages (not symlink-style extensions): they're cloned to
# /opt with node_modules baked at BUILD time, then registered here via
# `pi install <local-path>`. A local-path install is instant + in-place
# (pi loads the extension directly from /opt) + idempotent (no duplicate
# package entry on re-run), and stores a relative path that resolves into
# the image-layer /opt so it survives volume recreate. The fork/recall
# tools register on the NEXT pi start (extensions bind at startup). Guard
# on settings.json so we only install once per volume.
for _pkg in /opt/pi-fork /opt/pi-observational-memory; do
# pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the
# :latest-studio variant only) pi-studio (/studio command + studio_*
# tools + theme). These are pi packages (not symlink-style extensions):
# they're cloned to /opt with node_modules baked at BUILD time, then
# registered here via `pi install <local-path>`. A local-path install is
# instant + in-place (pi loads the extension directly from /opt) +
# idempotent (no duplicate package entry on re-run), and stores a relative
# path that resolves into the image-layer /opt so it survives volume
# recreate. The tools/command register on the NEXT pi start (extensions
# bind at startup). Guard on settings.json so we only install once per
# volume. /opt/pi-studio is present only in the studio variant; the
# `[ -d ]` test makes this a no-op everywhere else.
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
[ -d "$_pkg" ] || continue
_name=$(basename "$_pkg")
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
@@ -118,6 +177,25 @@ if command -v pi &>/dev/null; then
done
fi
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
# pi-studio binds its server to 127.0.0.1 inside the container, which a
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
# compose), start the `studio-expose` socat bridge in the background so a
# published port + `ssh -L` tunnel can reach Studio once the user runs
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
# (its secure default) unless explicitly opted in. Guarded on the studio
# variant (/opt/pi-studio) so it is a no-op in the plain image.
case "${STUDIO_EXPOSE:-}" in
1|true|TRUE|yes|on)
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
else
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
fi
;;
esac
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions.
+8 -1
View File
@@ -89,9 +89,16 @@ fi
# we append with a newline separator to avoid the ';;' parse error
# described at the top of this file. Guarded so repeated sourcing
# (e.g. `exec bash`) doesn't stack duplicates.
#
# The guard MUST stay shell-local (NOT exported): if it leaks into child
# processes, every nested shell -- crucially each tmux pane, which inherits
# the tmux server's env -- skips installing `history -a` and only persists
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
# means each new interactive shell re-installs its own per-prompt flush.
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1
DEVBOX_HIST_SET=1
fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
#
# WHY THIS EXISTS
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
# and the editor offers "refresh from disk". This helper closes the loop:
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
# Studio to see the update. Uses mtime polling (no inotify dependency,
# which isn't in the trixie-slim base).
#
# USAGE
# dot-watch <file.dot> [layout] [dpi]
# layout: dot|neato|fdp|circo|twopi (default: dot)
# dpi: output resolution (default: 150)
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
#
# EXAMPLES
# dot-watch /workspace/graph.dot
# dot-watch graph.dot neato 200
set -euo pipefail
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
LAYOUT="${2:-dot}"
DPI="${3:-150}"
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
OUT="${SRC%.dot}.png"
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
trap 'rm -f "$ERRLOG"' EXIT
render() {
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
else
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
sed 's/^/ /' "$ERRLOG"
fi
}
# portable mtime (GNU stat, fallback to BSD stat)
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
render
last="$(mtime "$SRC")"
while true; do
sleep "$INTERVAL"
[[ -f "$SRC" ]] || continue
now="$(mtime "$SRC")"
if [[ "$now" != "$last" ]]; then
last="$now"
render
fi
done
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# studio-expose — make a container-loopback pi-studio server reachable
# through a published Docker port.
#
# WHY THIS EXISTS
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
# reach Studio. This helper runs a socat TCP relay that listens on the
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
#
# SECURITY
# This intentionally exposes Studio beyond loopback — anything that can
# reach the container's network interface (and the host port you publish)
# can connect. Studio's tokenized URL is the only auth. Mitigate by
# publishing the host port on localhost only:
# ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"]
# and use `ssh -L` for remote access. Bridge nothing you don't intend to.
#
# USAGE
# studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765)
# studio-expose --help
#
# Typically: inside a pi session run `/studio --no-browser --port 8765`,
# then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in
# compose to auto-start it on container boot — see entrypoint-user.sh).
#
# Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path
# runs it backgrounded.
set -euo pipefail
PORT="${1:-${STUDIO_PORT:-8765}}"
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
exit 0
fi
case "$PORT" in
''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;;
esac
if ! command -v socat >/dev/null 2>&1; then
echo "studio-expose: socat not found in PATH" >&2
exit 1
fi
# Container's primary egress IPv4. In Docker the container hostname resolves
# to its eth0 address, so `hostname -I` lists it; we take the first
# non-loopback IPv4. We must bind this specific address rather than 0.0.0.0
# — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener
# (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a
# fallback only when iproute2 happens to be present (not in the base image).
BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)"
if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then
BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
fi
[ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
if [ -z "${BIND_IP:-}" ]; then
echo "studio-expose: could not determine container egress IP" >&2
exit 1
fi
echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}"
echo "studio-expose: open the tokenized URL pi-studio printed; if the host"
echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..."
echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)"
# fork: one child per connection (handles concurrent + long-lived WebSocket
# connections). reuseaddr: survive quick restarts. Studio need not be up yet
# — connections simply fail until `/studio --port ${PORT}` is running.
exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
# config that reaches the host and lets the user ProxyJump onward to LAN
# peers the host can reach. On native Linux we do nothing.
# peers the host can reach. On native Linux we render the same writable
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
# jump block, since LAN peers are reachable directly there.
#
# We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own
@@ -30,7 +32,9 @@
#
# CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
# auto → set up the host jump only on VM-backed hosts. The writable
# sidecar config (ControlPath redirect + Include) is always
# rendered, on every OS.
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
@@ -84,42 +88,72 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
}
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
# Native Linux host: LAN peers are reachable directly. Nothing to do.
exit 0
fi
# From here: MODE=jump, or MODE=auto on a VM-backed host.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# The ControlPath redirect in the generated config needs a writable directory
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
# master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
# ── Jump key (generated once; preserved across restarts) ──────────────
# ── Decide whether to set up the host jump ────────────────────────────
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
# we still render the config for the ControlPath redirect + Include.
NEED_JUMP=0
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
NEED_JUMP=1
fi
# ── Jump key (only when a jump is needed; generated once, preserved) ──
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
# is generated only on the very first start (or if the volume is wiped). When
# we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below.
KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
fi
fi
# ── Render the writable config ────────────────────────────────────────
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
# Linux they are all empty and only the ControlPath redirect + Include remain.
JUMP_BLOCK=""
LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
AUTOJUMP_BLOCK=""
if [ "$NEED_JUMP" = "1" ]; then
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
JUMP_BLOCK=$(cat <<EOF
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
EOF
)
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
# Scope reset to match-all so the Include applies to every target host.
@@ -127,14 +161,13 @@ Host *
Include ~/.config/devbox-shell/ssh-lan.conf
EOF
)
fi
fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK=""
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
ProxyJump host
EOF
)
fi
fi
INCLUDE_BLOCK=""
@@ -154,7 +188,9 @@ if [ -r "${HOME}/.ssh/config" ]; then
# Your own target hosts. Scope reset to match-all so this Include applies to
# every target (an Include is otherwise scoped to the enclosing Host block).
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
# here in ~/.ssh/config, which is typically bind-mounted read-only.
Host *
Include ~/.ssh/config
EOF
@@ -176,17 +212,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
${JUMP_BLOCK}
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
@@ -199,6 +225,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
# (b) fires only on first-ever start (or after the volume is reset) — so this
# is normally a one-time, one-line step per machine, with no file to locate.
if [ "$NEED_JUMP" = "1" ]; then
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF
@@ -221,5 +248,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
repeat this on container updates — only if that volume is reset.
EOF
fi
fi
exit 0
@@ -0,0 +1,31 @@
<!-- pi-devbox:managed-block — appended to the global AGENTS.md at image build
time (Dockerfile.variant), after pi-toolkit is cloned. Keep this short:
it is a pointer, the depth lives in the skill. -->
## Running inside pi-devbox
If the directory `/usr/local/lib/pi-devbox/` exists (or your shell prompt is
prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in a
**pi-devbox container** — a Docker environment whose persistence, networking,
DNS, host/LAN reachability, tmux, and Python/REPL behaviour differ from a normal
workstation. Before any task that touches **reaching the host or its LAN, SSH,
DNS/name resolution, what survives container recreate, running Python/REPLs,
tmux, or pi-studio**, read `~/.agents/skills/pi-devbox-environment/SKILL.md`.
Key reflex from that skill: **the deployment specifics are not universal** — the
host OS, hostnames, internal domains, and nameservers vary per instance and must
be discovered at runtime, never assumed. And interactive shell aliases
(`dssh`, `dscp`, `cat``bat`) do **not** exist in your non-interactive bash
tool, so spell out the underlying command (e.g.
`ssh -F "$HOME/.ssh-local/config" mac …`).
## Session start: load the mempalace skill
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
your tool list, **read `~/.agents/skills/mempalace/SKILL.md` before doing
non-trivial work** and follow its protocol: search the palace before answering
about past work, and write a diary entry before the session ends. This is
especially load-bearing here — a pi-devbox container is frequently recreated, so
the palace is your only memory across recreates. Without the habit it is just
storage, not memory. (The skill is the consumer side; feeding the palace is the
separate `opencode-mempalace-bridge` skill, if present.)
@@ -0,0 +1,47 @@
# Vendored fallback skills
Most directories here are **image-baked skills** that `entrypoint-user.sh`
symlinks into `~/.agents/skills/` on container start (only when a skill of the
same name is not already present, so a mounted `skillset` repo or a user
override always wins).
| skill | owner | how it gets here |
|-------|-------|------------------|
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
## Why fallbacks exist
The pi-toolkit global `AGENTS.md` tells every pi session to read
`~/.agents/skills/pi-extensions/SKILL.md` at start (to fix fork/recall
under-utilisation). That pointer dangles in a container started **without** the
private `skillset` repo mounted. Baking the skill closes that *availability*
gap. `mempalace` is baked for the same reason (memory continuity); since
nothing in pi-toolkit's `AGENTS.md` points to it, the pi-devbox managed block
(`pi-global-AGENTS.append.md`) also adds the matching *proactive-load*
directive ("load the mempalace skill at session start") so a new container
actually picks it up rather than relying on description-matching.
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
its skill file needed baking.
## Freshness model (layered — see Dockerfile.variant)
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
*floor*; at build time `Dockerfile.variant` copies `/opt/pi-extensions/skill/`
(the pinned, package-owned source) over it, so a normal build ships the fresh
package copy and a stale-ref / mirror build still ships the snapshot. Keep
`evaluate-extension-usage.py` alongside `SKILL.md` — the skill calls it via
`./`.
- **`mempalace`** — Option 2 only. The `mempalace` *consumer* skill lives only
in the private `skillset` repo (the `mempalace-toolkit` repo ships a
*different* skill, `opencode-mempalace-bridge`), so there is no public
package source to copy from. This snapshot is refreshed manually per release.
## Refreshing the snapshots
cp <skillset>/skills/pi-extensions/SKILL.md pi-extensions/SKILL.md
cp <skillset>/skills/pi-extensions/evaluate-extension-usage.py pi-extensions/
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
Snapshot provenance at last refresh: skillset `8e8db64`, pi-extensions pkg `a7f3044`.
@@ -0,0 +1,301 @@
---
name: mempalace
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
---
# MemPalace Agent Memory Protocol
## Overview
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
**Core principle:** Storage is not memory. Storage + protocol = memory.
## When to Load This Skill
- At the **start of every session** (proactively, before the user asks)
- When the user mentions **past conversations, decisions, or work**
- When working on a **new project or repository** for the first time
- When the user asks about **people, projects, or relationships**
## Session Lifecycle
### Phase 1: Wake Up (session start)
Run these immediately when a session begins, before responding to the user:
1. **Load palace overview:**
```
mempalace_status
```
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
2. **Read your recent diary:**
```
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
```
Scan for context about recent sessions — what was worked on, what matters, what's pending.
3. **Check the knowledge graph** for the user or active project if relevant:
```
mempalace_kg_query(entity="<project_or_person>")
```
Do NOT announce this to the user. Just do it silently to orient yourself.
### Phase 2: Active Session (during work)
#### Search Before You Speak
Before answering questions about past work, decisions, people, or projects:
```
mempalace_search(query="<keywords>", wing="<project>")
```
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
#### Mine New Projects
When working on a new codebase for the first time:
1. Check if it's already mined:
```
mempalace_list_wings
```
2. **Decide what to mine — docs first, code never (by default).**
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
**Mine by default:**
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
**Do NOT mine by default:**
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
- Test files, fixtures, generated code
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
3. **Before mining**, inspect the repo to estimate drawer count:
```bash
# Quick audit — what will actually get mined?
find <dir> -type f \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
```
A docs-heavy repo should produce ~510 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
4. Run the mine:
```bash
mempalace init --yes <directory>
mempalace mine <directory> --agent <your_agent_name>
```
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
```
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
```
6. After mining, reconnect to pick up the new embeddings:
```
mempalace_reconnect
```
If search errors occur after mining ("Error finding id"), repair the index:
```bash
mempalace repair --yes
```
#### Track Facts in the Knowledge Graph
When you learn new facts about people, projects, or relationships:
```
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
```
When facts change (ended, no longer true):
```
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
```
#### Cross-Reference with Tunnels
When content in one project relates to another, create a tunnel:
```
mempalace_create_tunnel(
source_wing="project_api", source_room="endpoints",
target_wing="project_db", target_room="schema",
label="API endpoints map to these DB tables"
)
```
#### Feeding opencode session history (opencode + mempalace-toolkit only)
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
**How to tell if it's set up:**
```
mempalace_list_wings
```
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
1. Check if the toolkit is installed: `which mempalace-session`.
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
### Phase 3: Wind Down (session end)
**Always write a diary entry before the session ends.** This is the most important habit.
```
mempalace_diary_write(
agent_name="<your_agent_name>",
entry="<AAAK compressed summary>",
topic="session-summary"
)
```
#### Why still write diaries when sessions may be mined automatically?
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
- Lessons learned, patterns noticed, pending items rolled forward
- Meta-observations that were never said aloud during the session
- Aggregate counts (commits shipped, bugs fixed, hours spent)
- A compressed, recency-scannable summary for the *next* agent's wake-up
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
#### AAAK Diary Format
Write diary entries in compressed AAAK format for efficiency. Structure:
```
SESSION:<date>|<what.you.worked.on>|
TASKS:
1.<task.description>→<outcome>|
2.<task.description>→<outcome>|
DISCOVERED:<unexpected.findings>|
ENTITIES:<people.or.projects.encountered>|
<importance: one to five stars>
```
Example:
```
SESSION:2026-04-28|api.refactor+db.migration|
TASKS:
1.refactored.auth.endpoints→split.into.3.modules|
2.added.user.roles.migration→postgres.enum.type|
DISCOVERED:legacy.session.table.unused.since.v2|
ENTITIES:ProjectX;Alice(reviewer)|
***
```
Rules:
- Use dots instead of spaces within phrases
- Use pipes as field separators
- Use arrows for cause/effect or transitions
- Stars indicate session importance (one to five)
- Keep it tight — a future agent should get the gist in seconds
#### What to Capture
Prioritize recording:
- **Decisions made** and their rationale
- **Discoveries** — things that surprised you or that a future session needs to know
- **Unfinished work** — what's pending, what was deferred
- **User preferences** observed during the session
- **Entities encountered** — people, projects, tools, services
### Phase 4: Fact Updates
If facts changed during the session, update the knowledge graph before writing the diary:
```
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
```
## Palace Structure
### Wings
Wings are top-level categories, typically one per project or domain:
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
#### Multi-harness palace
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
- `pi_<uuid>.jsonl` → pi session
- `<slug>_ses_<id>.jsonl` → opencode session
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
### Rooms
Rooms are aspects within a wing:
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
- Diary entries go into rooms by topic tag
### Drawers
Drawers hold verbatim content — never summarized, always searchable.
### Tunnels
Cross-wing connections linking related content across projects.
### Knowledge Graph
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
## Troubleshooting
| Problem | Fix |
|---|---|
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
| Stale results after external changes | Call `mempalace_reconnect` |
## Anti-Patterns
- **Don't guess when you can search.** If a question touches past work, search first.
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
@@ -0,0 +1,207 @@
---
name: pi-devbox-environment
description: >-
Operate correctly inside a pi-devbox container. Load when running inside
pi-devbox (detection: the directory `/usr/local/lib/pi-devbox/` exists, the
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
resolution, what survives container recreate (persistence vs ephemerality),
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the
persistence model, the interactive-vs-tool-shell alias gotcha
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
0-index constraint, uv-first Python, and pi-studio reachability. This skill
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
nameservers, and even the host OS vary per deployment and MUST be discovered
at runtime, never assumed or hardcoded.
---
# pi-devbox environment
You are (or may be) running inside **pi-devbox**: a Docker container that ships
pi, MemPalace, and a curated tool stack, with the host source tree mounted at
`/workspace`. This skill is about the *container-shaped* facts that change how
you should act — things that are easy to get wrong because they differ from a
normal workstation shell.
> **Golden rule: this environment is a template, not a fixed deployment.**
> The host could be macOS, Windows, or Linux. There may or may not be LAN
> peers, a VPN, split-DNS, a skillset mount, or the `-studio` variant. Detect
> and verify the specifics live (commands below) — do **not** assume any
> particular hostname, domain, nameserver, or OS. Where this skill shows
> example values they are illustrative placeholders.
## 0. Am I in pi-devbox, and what's true *here*?
Cheap detection signals (any one is sufficient):
```sh
[ -d /usr/local/lib/pi-devbox ] && echo "pi-devbox image"
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
```
Then orient before acting:
```sh
cat /etc/os-release | head -2 # container distro (usually Debian)
ls -la /usr/local/lib/pi-devbox/ # which devbox helpers exist
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
```
## 1. Persistence vs ephemerality — know before you write
The container has **three storage tiers with very different lifetimes**. Pick
the right one or work is silently lost on the next recreate/update.
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|---|---|---|---|---|
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), often `~/.mempalace` | yes | yes (lives on host) | yes |
| **Named volume** | `~/.pi`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
Practical consequences:
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
what you write appears with the user's normal ownership on the host).
- **Runtime-installed system packages and language toolchains are ephemeral.**
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
project manifest, not an ad-hoc `apt install`. Tell the user when you install
something that won't survive.
- **`~/.pi` is a named volume**, so things baked into the *image* under
`/home/<user>/...` are **shadowed** by the volume on existing containers and
only seen on a fresh volume. Image-owned content that must always be live
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
in by the entrypoint — not dropped into a home directory that a volume covers.
## 2. Interactive shell vs. your tool shell (a real footgun)
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
are "command not found" there — you must spell out the underlying command.
| Interactive alias | Non-interactive equivalent to actually run |
|---|---|
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
## 3. Reaching the Docker host and its LAN over SSH
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
`~/.ssh-local/config`. It always provides:
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
can't be created under it), plus `Include ~/.ssh/config`.
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
Linux host the LAN is usually reachable directly and this jump block is
omitted — **so don't assume a jump path exists; read the sidecar.**
Use it (remember §2 — spell it out in tool bash):
```sh
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
```
Two related mechanisms (don't reinvent them):
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
a `ControlPath` under the read-only `~/.ssh`, override with
`-o ControlPath=none` (or use the sidecar, which already redirects it).
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
remote host; it has its own writable-socket fallback. See the `pi-extensions`
skill for that path.
## 4. DNS / name resolution — environment-specific, verify live
How a name resolves here is **not universal** and depends on the host's
networking. The container's own resolver is just `/etc/resolv.conf`, but the
*host* (which you reach via §3, and whose DNS the container may inherit) can use
**split-horizon DNS** to send certain internal domains to specific nameservers
while everything else goes to a default resolver/VPN gateway. The mechanism is
OS-specific and **may not be present at all**:
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
real split-DNS map — *for that one machine.*
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
routing) or `/etc/resolv.conf` `search`/`nameserver`.
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
Operating rules:
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP**
it is per-deployment and changes between users and even VPN states.
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
3. **Reachability needs both DNS *and* a route.** A name resolving to an
internal address is useless if packets to that subnet don't have a path
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
won't connect."
4. If you discover deployment-specific facts (a domain, a nameserver, a
reachable peer), prefer recording them in MemPalace over baking them into
code or this skill.
## 5. tmux is 0-indexed — don't change it
The image ships `/etc/tmux.conf` with `base-index 0` / `pane-base-index 0`
because **pi-studio hard-codes its tmux send target to `<session>:0.0`.** If you
(or a user `~/.tmux.conf`) set `base-index 1`, pi-studio fails with "can't find
window: 0". Leave the indexing alone in this environment.
## 6. Python and other languages: uv-first, toolchains are ephemeral
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
- Throwaway REPL: `uv run --with ipython ipython`
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
do not survive `down -v` or an image update. Flag this when installing.
## 7. pi-studio reachability (only in the `-studio` variant)
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
- **Host networking** (`network_mode: host`): container loopback == host
loopback; open the tokenized URL on the host. (Changes
`host.docker.internal` semantics — weigh against §3 LAN jump.)
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
`socat` relay from the container's external interface to its loopback, so a
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
The real auth token comes from the `/studio` slash command (`/studio --status`
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch`
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
## 8. MemPalace is the shared brain
MemPalace data is usually a **host bind-mount**, so a pi on the host and a pi in
this container share one palace (SQLite WAL: many readers, one writer). Use it
to persist the deployment-specific facts this skill deliberately refuses to
hardcode. Details are in the `mempalace` skill.
## Checklist before acting in this environment
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
- [ ] Touching tmux indexing? → don't (§5).
@@ -0,0 +1,298 @@
---
name: pi-extensions
description: >-
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-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`) |
When the user asks how to use "the X extension", **check both locations**`find ~/.pi/agent -maxdepth 4 -name "*X*"` covers both. 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.
## 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 four 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.
### 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 (observed behavior)
Forks **mostly** honor explicit decision-authority instructions, but not infallibly. Observed pattern from real sessions:
- **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.
**Practical rules:**
- State decision authority explicitly, every time, even when "report only" feels redundant.
- For high-stakes write authority, verify the fork's actions afterwards (`git status`, `git log -1`, file diffs) rather than assuming compliance.
- If a boundary violation is unacceptable (e.g., compliance review, sandboxed exploration, "don't touch prod"), do not give the fork write tools at all — keep it strictly in analysis mode.
- 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.
---
## 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
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
```
Restart pi after install. Enable `observational-memory.debugLog` if you want
the next window instrumented.
### 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
```
---
## 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
```bash
# 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 local**`mempalace` 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).
```bash
# 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:
```bash
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.
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Evaluate pi-fork / pi-observational-memory usage from pi session transcripts.
Mines pi's session .jsonl transcripts and reports:
- per-tool call counts (highlighting `fork` and `recall`)
- per-session fork/recall breakdown
- obsmem passive activity: compaction events, observations carried,
relevance-tier distribution, tokensBefore
Works on any machine. Point it at one or more session roots; by default it
scans ~/.pi/agent/sessions (the standard pi location, host or container).
Usage:
./evaluate-extension-usage.py # ~/.pi/agent/sessions
./evaluate-extension-usage.py /path/to/sessions ... # explicit roots
./evaluate-extension-usage.py --host HOST /path ... # label a root (for combined host+container runs)
For a true host+container picture, run once per machine (or copy each
machine's ~/.pi/agent/sessions here) and pass all roots together.
"""
import json, sys, os, glob, re, collections, argparse
TIER_RE = re.compile(r'\[(low|medium|high|critical)\]')
OBS_LINE_RE = re.compile(r'^\[[0-9a-f]{12}\] ', re.M)
def walk_tools(x, counter):
if isinstance(x, dict):
tn = x.get("toolName")
if tn:
counter[tn] += 1
for v in x.values():
walk_tools(v, counter)
elif isinstance(x, list):
for v in x:
walk_tools(v, counter)
def analyze(roots):
files = []
for r in roots:
if os.path.isfile(r) and r.endswith(".jsonl"):
files.append(r)
else:
files += glob.glob(os.path.join(r, "**", "*.jsonl"), recursive=True)
files = sorted(set(files))
tool_total = collections.Counter()
per_session = []
compactions = []
for f in files:
tc = collections.Counter()
with open(f, errors="ignore") as fh:
for ln in fh:
ln = ln.strip()
if not ln:
continue
try:
o = json.loads(ln)
except Exception:
continue
walk_tools(o, tc)
if o.get("type") == "compaction":
s = o.get("summary", "") or ""
compactions.append({
"file": os.path.basename(f),
"tokensBefore": o.get("tokensBefore"),
"observations": len(OBS_LINE_RE.findall(s)),
"tiers": dict(collections.Counter(TIER_RE.findall(s))),
})
tool_total.update(tc)
per_session.append((os.path.basename(f)[:10], tc.get("fork", 0),
tc.get("recall", 0), sum(tc.values())))
return files, tool_total, per_session, compactions
def main():
ap = argparse.ArgumentParser()
ap.add_argument("roots", nargs="*",
default=[os.path.expanduser("~/.pi/agent/sessions")])
args = ap.parse_args()
files, tool_total, per_session, comp = analyze(args.roots)
if not files:
print("No .jsonl transcripts found under:", args.roots, file=sys.stderr)
sys.exit(1)
print(f"=== {len(files)} transcripts under {args.roots} ===\n")
print("Tool call totals:")
for t, c in tool_total.most_common():
mark = " <== pi-fork" if t == "fork" else (" <== obsmem recall" if t == "recall" else "")
print(f" {c:6d} {t}{mark}")
fk = tool_total["fork"]; rc = tool_total["recall"]
fk_sess = sum(1 for p in per_session if p[1])
rc_sess = sum(1 for p in per_session if p[2])
print(f"\npi-fork: {fk} calls across {fk_sess} sessions")
print(f"recall: {rc} calls across {rc_sess} sessions"
+ (" (!) zero recall over the window — see SKILL.md calibration note" if rc == 0 else ""))
if comp:
tot_obs = sum(c["observations"] for c in comp)
tb = [c["tokensBefore"] for c in comp if c["tokensBefore"]]
print(f"\nobsmem passive: {len(comp)} compactions, {tot_obs} observations carried"
+ (f", avg tokensBefore {sum(tb)//len(tb):,}" if tb else ""))
agg = collections.Counter()
for c in comp:
agg.update(c["tiers"])
if agg:
print(" relevance tiers:", dict(agg))
else:
print("\nobsmem passive: no compaction events found "
"(short sessions, or obsmem not active on these transcripts)")
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# check-base-hash.sh — guard the base-rebuild invariant.
#
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
# into the base_tag hash in the docker-publish workflow. Otherwise a
# ref-only change to that dependency does not change the base hash, the
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
# the dependency fix silently fails to land. This is the v1.1.2-class
# staleness footgun (then it was mempalace-toolkit; this guard stops the
# next one before it ships).
#
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
set -euo pipefail
cd "$(dirname "$0")/.."
WF=".gitea/workflows/docker-publish.yml"
DF="Dockerfile.base"
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
# brace-group in the "Compute base tag" step. This lives in a separate
# file from the workflow, so scanning $WF here is free of the self-match
# hazard an inline workflow step would have.
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
if [ -z "$block" ]; then
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
exit 1
fi
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
fail=0
for r in $refs; do
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
fail=1
fi
done
if [ "$fail" = 0 ]; then
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
fi
exit $fail
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env bash
# Runtime post-recreate verification for pi-devbox.
#
# Verifies that after `docker compose up -d --force-recreate`:
# - The new image is actually live (pi version matches, when an expected
# version is supplied — see the version note below)
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
# nvim data, uv cache, ssh-local)
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
# pi-observational-memory / (studio variant) pi-studio package registrations
# - Shell defaults re-seeded from /etc/skel-devbox
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
# - /opt toolkits intact
# - Known expected-absences don't regress
#
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
# a recreated container's persisted volumes or the entrypoint's runtime
# deploy. This script is its runtime counterpart: it inspects what is actually
# live in the container you are sitting in after a recreate.
#
# It is NOT baked into the published Docker Hub image; run it from a checkout of
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
# `docker pull` consumer is not the audience and will not have this file.
#
# Version note: pi's version is resolved from `latest` at CI build time and is
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
# So unlike opencode-devbox, this script cannot self-derive an expected version
# from the Dockerfile. Pass --expected-version to assert a match; without it the
# live pi version is reported as an informational WARN, not a failure.
#
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
# 2 usage error
set -euo pipefail
EXPECTED_VERSION=""
VARIANT=""
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--expected-version)
EXPECTED_VERSION="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
*)
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
exit 2
;;
esac
done
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
# /opt/pi-studio; the plain variant does not.
if [ -z "$VARIANT" ]; then
if [ -d /opt/pi-studio ]; then
VARIANT="studio"
else
VARIANT="plain"
fi
fi
# Print header with git context
echo "=== Recreate sanity check (variant: $VARIANT) ==="
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
else
echo " Repo HEAD: (not a git repo or no tags)"
fi
echo
echo "-- pi version --"
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
if [ -n "$EXPECTED_VERSION" ]; then
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
pass "pi version $ACTUAL_VERSION"
else
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
else
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
fi
else
fail "pi --version failed"
fi
echo
echo "-- Persisted named volumes (must survive --force-recreate) --"
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
# keybindings symlink. Must exist and be non-empty after recreate.
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
pass "~/.pi/agent exists and is non-empty"
else
fail "~/.pi/agent missing or empty"
fi
# shell history volume (devbox-shell-history). An empty .bash_history right
# after recreate is NORMAL — only the mount point must exist.
if [ -d "$HOME/.cache/bash" ]; then
pass "~/.cache/bash exists as directory"
else
fail "~/.cache/bash missing or not a directory"
fi
# remaining persisted volumes — mount points must exist
for vol_path in \
"$HOME/.local/share/zoxide" \
"$HOME/.local/share/nvim" \
"$HOME/.local/share/uv" \
"$HOME/.ssh-local"; do
if [ -d "$vol_path" ]; then
pass "$vol_path exists"
else
fail "$vol_path missing or not a directory"
fi
done
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
# devbox-palace named volume is commented out; the palace is reached via the
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
# is NOT a recreate regression here.
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
else
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
fi
else
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
fi
echo
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
# keybindings symlink (pi-toolkit)
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
else
fail "~/.pi/agent/keybindings.json missing or not a symlink"
fi
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
# every start (directs the agent to read the pi-extensions skill at session start)
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
else
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
fi
# extensions deployed (pi-extensions) — expect ≥4 *.ts
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
if [ "$EXT_COUNT" -ge 4 ]; then
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
else
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
fi
# mempalace.ts bridge symlink
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
else
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
fi
# settings.json bootstrapped
if [ -f "$HOME/.pi/agent/settings.json" ]; then
pass "~/.pi/agent/settings.json bootstrapped"
else
fail "~/.pi/agent/settings.json missing"
fi
# settings.json merge: the entrypoint deep-merges new template keys into a
# preserved settings.json on every start, so config added in an image upgrade
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
# Assert those blocks are present and that the file is still valid JSON.
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
else
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
fi
fi
# pi package registrations (pi install <local-path> → recorded in settings.json)
if [ -f "$HOME/.pi/agent/settings.json" ]; then
for pkg in pi-fork pi-observational-memory; do
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
pass "$pkg registered in settings.json"
else
fail "$pkg not registered in settings.json"
fi
done
if [ "$VARIANT" = "studio" ]; then
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
pass "pi-studio registered in settings.json"
else
fail "pi-studio not registered in settings.json (studio variant)"
fi
fi
fi
echo
echo "-- ssh ControlMaster dir --"
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
pass "/tmp/sshcm exists with mode 700"
else
fail "/tmp/sshcm missing or not mode 700"
fi
echo
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
if [ -f "$HOME/.bash_aliases" ]; then
pass "~/.bash_aliases exists"
else
fail "~/.bash_aliases missing"
fi
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
# be exported: if it leaks into child processes, nested shells (esp. tmux
# panes) skip installing `history -a` and lose in-memory history on abrupt
# termination. Assert a child login shell still wires up the per-prompt flush.
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
else
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
fi
if [ -f "$HOME/.inputrc" ]; then
pass "~/.inputrc exists"
else
fail "~/.inputrc missing"
fi
echo
echo "-- cli_utils bind-mount --"
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
pass "/workspace/cli_utils exists with .git subdir"
else
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
fi
echo
echo "-- Baked /opt toolkits --"
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
if [ -d "$opt_path" ]; then
pass "$opt_path exists"
else
fail "$opt_path missing"
fi
done
if [ "$VARIANT" = "studio" ]; then
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
pass "/opt/pi-studio exists with prebuilt client bundle"
else
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
fi
fi
# mempalace MCP entrypoint on PATH
if command -v mempalace-mcp >/dev/null 2>&1; then
pass "mempalace-mcp on PATH"
else
fail "mempalace-mcp not on PATH"
fi
echo
echo "-- Known expected-absences (regressions vs by-design) --"
if ! command -v go >/dev/null 2>&1; then
warn "go absent — expected unless image built with INSTALL_GO=true"
else
pass "go is on PATH"
fi
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="
+80
View File
@@ -15,6 +15,8 @@
# - mempalace bridge symlink present
# - settings.json bootstrapped
# - pi-fork + pi-observational-memory registered via `pi install`
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
# client bundle present + registered via `pi install`
# - image size within threshold
set -euo pipefail
@@ -76,6 +78,28 @@ run "graphviz (dot)" "dot -V"
run "imagemagick" "magick --version"
run "yq" "yq --version"
run "tldr (tealdeer)" "tldr --version"
run "socat" "socat -V"
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
run "image-baked pi-devbox-environment skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
run "global-AGENTS append snippet present" \
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
run "pi-devbox block merged into pi-global-AGENTS.md" \
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
run "mempalace session-start pointer merged into global AGENTS.md" \
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
# Vendored fallback skills (so a no-skillset container still resolves the
# AGENTS.md 'read the pi-extensions skill' pointer).
run "image-baked pi-extensions fallback skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md"
run "pi-extensions skill ships its helper" \
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py"
run "image-baked mempalace fallback skill" \
"test -f /usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
# Layered freshness: when the pinned pi-extensions clone carries the skill, the
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
run "pi-extensions skill refreshed from package when present" \
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
echo ""
@@ -95,6 +119,42 @@ run "pi-fork clone + node_modules" \
run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
# pi-studio is present only in the :latest-studio variant. Auto-detect by
# probing /opt/pi-studio so this one script covers both variants.
if docker run --rm --entrypoint="" "$IMAGE" sh -c 'test -d /opt/pi-studio' >/dev/null 2>&1; then
STUDIO_VARIANT=1
echo " ️ pi-studio detected — running studio assertions"
run "pi-studio clone + node_modules" \
"test -f /opt/pi-studio/package.json && test -d /opt/pi-studio/node_modules"
run "pi-studio prebuilt client bundle" \
"test -f /opt/pi-studio/client/studio-client.js"
else
STUDIO_VARIANT=0
echo " ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
fi
# ── Build provenance (manifest + OCI labels) ─────────────────────────
echo ""
echo "── Build provenance ──"
run "/etc/pi-devbox/build-manifest.json present" \
"test -f /etc/pi-devbox/build-manifest.json"
run_expect "manifest records pi-extensions component" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
run_expect "manifest records pi_version" \
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
# Every component must be a resolved commit (or null for pi-studio in the
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
run "manifest has no unresolved ('unknown') components" \
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
# OCI labels live in the image config, not the container fs — inspect them
# from the host docker rather than via `docker run`.
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1))
else
printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1))
fi
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
echo ""
echo "── Runtime deployment ──"
@@ -116,6 +176,9 @@ for i in $(seq 1 45); do
if docker exec "$CID" sh -c '
test -L /home/developer/.pi/agent/keybindings.json && \
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
test -L /home/developer/.agents/skills/pi-devbox-environment && \
test -L /home/developer/.agents/skills/pi-extensions && \
test -L /home/developer/.agents/skills/mempalace && \
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
[ "$count" -ge 4 ]
' >/dev/null 2>&1; then
@@ -137,6 +200,9 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
@@ -150,6 +216,20 @@ done
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
# pi-studio registration (studio variant only) — registered by the same
# entrypoint-user.sh local-path install loop as fork/obsmem.
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
for i in $(seq 1 15); do
if docker exec "$CID" grep -q pi-studio \
/home/developer/.pi/agent/settings.json 2>/dev/null; then
break
fi
sleep 1
done
exec_test "pi-studio registered (/studio command + studio_* tools)" \
'grep -q pi-studio $HOME/.pi/agent/settings.json && echo ok'
fi
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'