v1.10.1 (run 707) failed with ZERO failing steps: every step Success, `smoke` printing "101 passed, 0 failed", `smoke-studio` "104 passed, 0 failed", both jobs red. The clipboard fix fromf3b3748worked exactly as predicted; this is a second, independent defect that run 704 had masked. CHAIN, measured end to end rather than reasoned: `ARG BASE_IMAGE` has no default on purpose (the two-phase build always supplies it), which trips BuildKit's InvalidDefaultArgInFrom check. buildx attaches that warning's source context to the build metadata as buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on ONE line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a `name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single line at exactly 65536 chars, so the closing delimiter was cut off: invalid format delimiter 'ghadelimiter_...' not found before end of file and the runner failed the job while attributing it to no step at all. v1.9.4 run 695 SUCCESS: longest metadata line 56355, 0 delimiter errors v1.10.0 run 704 failed : longest metadata line 65536, 1 delimiter error v1.10.1 run 707 failed : longest metadata line 65536, 1 delimiter error 65536 = 2^16: cut AT the cap, not merely long. Independent route via file size rather than log parsing: 42251 B at v1.9.4 (base64 56335, matching the log) vs 50034 B now (base64 66712, truncated). The cap corresponds to a 49152 B Dockerfile, so this cycle's comment growth crossed it by 882 B. Located by asking which top-level metadata key CONTAINS the base64, instead of assuming: it is buildx.build.warnings -> sourceInfo -> data in BOTH runs. THIS IS THE SECOND FIX FOR THIS BUG. The first, `provenance: false` on the two smoke build steps, was committed as784fad7and is REVERTED here: a real buildx on another host showed provenance metadata present in both modes with a longest string of 71 chars, i.e. the base64 was never in provenance. Shipping it would have left the release broken a third time while looking like a fix. Also rejected, each measured: floating action tags moving (act action-bundle hashes byte-identical between runs 695 and 707, all four) and the build-check annotation text changing (byte-identical). FIX: `# check=skip=InvalidDefaultArgInFrom` as the FIRST line of Dockerfile.variant — BuildKit parses `# check=` only before any other line, so placement is load-bearing. No honest default exists for BASE_IMAGE: `scratch` would satisfy the linter while being a lie, and would convert today's instant "invalid reference format" into a failure deep in the build. Verified against the actual edited file with `docker buildx build --check`: "Check complete, no warnings found." Zero warnings means no sourceInfo, so the longest metadata line drops 65536 -> ~1936 and the file's SIZE stops gating CI. GUARD: scripts/check-dockerfile-directives.sh, wired into BOTH lint.yml and docker-publish.yml's lint-gate, because a check that gates only `push` lets a tag regress — the adoption slip that let doc-drift land 27 h after the v1.9.3 tag. It also fails if ARG BASE_IMAGE gains a default, so the directive cannot rot into guarding a check that can no longer fire. Truth table, exit codes: present 0, removed 1, demoted to line 2 → 1, ARG defaulted 1, file missing 2 (a gate that cannot run must not pass). Release renamed v1.10.1 -> v1.10.2 with both failed tags left standing as tombstones. Docs swept again (README "since" marker, Dockerfile decision comments) because CI reads them from the TAG. Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor, lint-shell (17 files now), dockerfile-directives all rc=0.
pi-devbox
A self-contained Docker image for running pi — the pi coding-agent — in an isolated, reproducible Linux environment with a curated set of developer tooling, AI memory, and shell improvements.
pi-devbox is opinionated about what's inside but unopinionated about how
you use it: a single docker compose up gives you an interactive
container with pi, a stack of modern CLI tools, MemPalace for persistent
agent memory across sessions, and a UID-aligned /workspace mount so
files you edit inside the container appear with your normal ownership
on the host.
What's inside
The pi coding-agent
pi— the pi-coding-agent CLI (@earendil-works/pi-coding-agent)pi-toolkit— keybindings, AWS env loader, settings templatepi-extensions— TypeScript extensions for pi (preview, MCP bridges, mempalace integration, etc.)pi-fork— theforktool for spawning sub-agentspi-observational-memory— durable session memory: the ledger that makes compaction cheap, plus therecalltool. Seedocs/observational-memory.mdpi-atelier— TUI sidebar: ordered panels, split-pane, themes. Pinned to an audited tag; see Version pins
MemPalace (AI memory)
mempalace— local-first agent memory system (29 MCP tools)mempalace-toolkit— bash wrappers for session/docs mining- ChromaDB embedding model pre-warmed at build time (
all-MiniLM-L6-v2)
The host-mounted palace at ~/.mempalace is shared across the host and
this container so all your agents share one brain. To instead share a palace
across several containers/harnesses, set MEMPALACE_REMOTE_URL to a shared
MemPalace HTTP endpoint (see .env.example and docker-compose.mempalace.yml);
the bridge then connects over HTTP and spawns no local server.
Modern CLI tooling
| Tool | Purpose |
|---|---|
nvim |
Neovim text editor (modal / vi-style) |
nano |
Small non-modal editor (on-screen shortcut hints) |
micro |
Modern non-modal editor (Ctrl+S/Ctrl+Q keys, mouse, syntax highlighting) |
tmux |
Terminal multiplexer (configured for 0-indexed sessions) |
ripgrep, fd |
Fast file content / filename search |
fzf |
Fuzzy finder |
bat |
Syntax-highlighted cat |
eza |
Modern ls |
zoxide |
Smart cd |
jq, yq |
JSON / YAML query and transformation |
tldr (tealdeer) |
Quick command examples |
git, git-lfs, git-crypt |
Git + extensions |
gitleaks |
Secret scanning pre-commit hook |
gosu |
Privilege de-escalation in entrypoint |
htop, tree, less |
Inspection utilities |
The default $EDITOR is nvim. Three editors ship so you can pick your
comfort level — if you'd rather not use a vi-style editor, nano and micro
are both non-modal. Set your preference with export EDITOR=micro (or nano)
in your shell profile, and/or git config --global core.editor micro.
Neovim ships with a system-wide default (/etc/xdg/nvim/sysinit.vim) that turns
on termguicolors, so its colours render in 24-bit instead of a muddy
256-colour fallback over ssh/kitty. The kitty-terminfo entry is also bundled
so TERM=xterm-kitty is understood. Override either in your own
~/.config/nvim.
Document and image tooling
pandoc— universal Markdown↔HTML/Org/RST/etc. convertertypst— markup-based typesetting, wired up as pandoc's--pdf-engine(see Generating a PDF with pandoc + typst)graphviz—dotrendering for diagram pipelinesimagemagick— image conversion / resizing (invoked asmagick)
Browser automation
agent-browser— CLI for driving a real headless browser: open pages, click/fill/eval, snapshot the DOM, take screenshots. Useful whenever a task involves a web UI or verifying how a page actually renders (live DOM, WebGL, layout, popup positioning) instead of guessing from source.playwright+ a pre-installed headless Chromium back it.AGENT_BROWSER_EXECUTABLE_PATHis preset to the baked browser via a stable/usr/local/bin/agent-chromesymlink (insulated from Playwright's per-version/arch install directory), soagent-browser open <url>works out of the box with no setup. Runagent-browser skills get core --fullfor the command set and workflow patterns.socat— TCP bridge used bystudio-exposeto reach pi-studio's loopback-bound server from outside the container (see Using pi-studio)
Language toolchains
python3+python3-venv+python3-pip(system Python)uv+uvx— fast Python package manager (preferred over pip/venv)nodejs(v22) +npmgcc,g++,make— C/C++ build toolsrustup-init— Rust toolchain installer (toolchains opt-in at runtime)- Optional
INSTALL_GO=truebuild arg for Go
For Python REPLs and notebooks beyond the system interpreter, see the uv-driven REPL recipes section.
Cloud + secrets
- AWS CLI v2 — for SSO + Bedrock auth
gitea-mcp— MCP server for Gitea APIage,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) by multiplexing many ssh calls over one TCP flow. - 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
~/.sshis handled transparently: a per-hostControlPathunder it (common CGNAT configs like~/.ssh/cm/...) is redirected to a writable socket dir for bothpi --sshanddssh/dscp.
Quickstart
Prerequisites
- Docker or OrbStack (recommended on macOS)
- Optional: AWS credentials configured on the host if you'll use the Bedrock LLM provider
Pull and run
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 -u developer devbox bash
You're now in the container as user developer with pi on PATH and
your host workspace mounted at /workspace.
To start pi:
pi
First-run pi-toolkit and pi-extensions install steps run automatically
on container start; symlinks are written to ~/.pi/agent/ on the
named volume (so they persist across container recreations).
Stop / recreate / update
docker compose down # stop, keep volumes
docker compose down -v # stop, wipe per-container volumes (palace data is bind-mounted, so unaffected)
docker compose pull # fetch latest image
docker compose up -d --force-recreate
Image variants
Currently published:
| Tag | Includes | Size (approx.) |
|---|---|---|
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 (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 |
Both variants ship typst as the pandoc PDF engine
(pandoc --pdf-engine=typst), a single ~30 MB static binary, so PDF export from
Studio/pandoc works out of the box — no separate -tex variant needed.
texlive-xetex stays the higher-fidelity fallback (install on demand).
Using pi-studio (-studio variant)
The -studio images bundle 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:
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):
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:
studio-expose & # bridges $STUDIO_PORT (default 8765); --help for details
studio-exposeruns in the foreground (it's asocatrelay) — 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 usessh -Lfor 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:
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:
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):
- In a container shell — start the bridge (skip if
STUDIO_EXPOSE=1is set in compose, which auto-starts it):studio-expose & - In your pi session (the pi TUI in the container) — start Studio and
print the tokenized URL.
/studiois a slash command you type in the TUI, not a shell command:Copy the/studio --no-browser --port 8765 /studio --status # reprint the URL anytimehttp://…:8765/?token=<token>it prints. This is where the real token comes from — notstudio-expose. - On your laptop — open the ssh port-forward alongside mosh:
ssh -L 8765:127.0.0.1:8765 user@docker-host - In your laptop browser — open
http://127.0.0.1:8765/?token=<token>(keep the port and token verbatim; only the host part is127.0.0.1).
Order check: nothing listens on the container's
127.0.0.1:8765until 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) usestypst, shipped in the base image as the pandoc PDF engine (pandoc --pdf-engine=typst). For LaTeX-exact output you can installtexlive-xetexon demand as a heavier fallback. HTML export, KaTeX, Mermaid, and all REPL features work regardless.
Generating a PDF with pandoc + typst
The base ships pandoc (front-end) and typst (PDF engine), so Markdown → PDF
works out of the box:
pandoc doc.md --pdf-engine=typst -o doc.pdf
The base patches pandoc's bundled typst template so it defaults to the
Libertinus Serif font. Without that patch a naked --pdf-engine=typst
fails with error: font fallback list must not be empty, because the upstream
template leaves the font unset. To pick a different face, pass one of the fonts
typst can see (typst fonts lists them — DejaVu Serif/Sans/Mono, Libertinus
Serif, New Computer Modern):
pandoc doc.md --pdf-engine=typst -V mainfont="New Computer Modern" -o doc.pdf
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:
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.
Terminal UI mode: fullscreen is the default (since v1.10.2)
pi 1.0.0 changed the default terminal UI mode to fullscreen, and this
image adopts upstream's default rather than overriding it. Fullscreen draws into
the terminal's alternate screen, so pi's transcript no longer accumulates in your
terminal's native scrollback — you scroll inside pi instead, and on exit pi
prints the transcript (fullscreenExitOutput).
That is a real behaviour change if you were used to the old mode, so here is the way back. Nothing in the image is pinned, so all three routes below work:
| Scope | How |
|---|---|
| Permanently, for every session | add "tuiMode": "regular" to ~/.pi/agent/settings.json |
| One session | pi --tui-mode regular |
| One project only | add "tuiMode": "regular" to that project's .pi/settings.json — project settings override the agent directory |
The settings file is plain JSON and tuiMode is top-level, so the minimal
permanent change is:
# merge the key without disturbing the rest of the file (python3 is always present)
python3 - <<'PY'
import json, pathlib
p = pathlib.Path.home() / ".pi/agent/settings.json"
d = json.loads(p.read_text()) if p.exists() else {}
d["tuiMode"] = "regular" # "fullscreen" is pi's default
p.write_text(json.dumps(d, indent=2) + "\n")
PY
If you stay on fullscreen, four related settings are worth knowing — all
documented in pi's own docs/settings.md under Terminal and display:
fullscreenExitOutput—"transcript"(default) or"resume-hint": what pi leaves behind in the terminal when fullscreen exits.fullscreenScrollbar—"auto"(default),"always","hidden".fullscreenCopyOnSelect—trueby default; selecting text copies it.fullscreenWheelScrollLines—"auto"by default. Relevant here in particular: this container is normally driven over SSH, and over SSH"auto"accelerates fast wheel spins to at most 6 lines per event (local macOS terminals accelerate on their own, so there it moves one line).Alt+wheel moves five times as far.
Two notes specific to this image:
- tmux. Fullscreen uses the alternate screen, so
tmuxcopy-mode scrollback shows the pane's history around pi, not pi's transcript. Scroll within pi, or use"tuiMode": "regular"if you rely on tmux copy-mode to search the conversation. - pi-atelier. The bundled sidebar works in both modes — upstream has handled
regular and fullscreen renderers separately since pi 0.84, including
fullscreen divider dragging and keeping sidebar text out of fullscreen
selection — so switching back to
regulardoes not cost you the sidebar.
Using pi-atelier (TUI sidebar)
pi-atelier is bundled in both variants (vendored at /opt/pi-atelier,
pinned — see Version pins). It adds two
things to pi's terminal UI:
- a status rail — activity, token/cost metrics, context usage, model, git state, extension statuses, and a menu;
- a sidebar — ordered panels (agent, activity, alerts, TODOs, context, workspace, usage, tools) in a split pane beside the transcript.
Nothing needs installing; the entrypoint registers it on container start, and it
binds on the next pi start (or /reload).
| Action | How |
|---|---|
| Open the atelier menu | alt+a, or /atelier |
| Toggle the sidebar for this session | /atelier sidebar on / off |
| Change settings persistently | atelier menu → Settings, then Save |
| Turn the whole thing off | DEVBOX_ATELIER=0 in .env |
If your terminal or keymap swallows alt+a, use /atelier and pick a different
shortcut in the config file below.
Config
Config lives at ~/.pi/agent/pi-atelier.json on the devbox-pi-config volume,
seeded from pi-toolkit with container-appropriate defaults: compact density,
context warnings at 60/85 % (earlier than upstream's 70/90), sidebar tool names
on, and desktop completion notifications off (a container has nowhere useful
to pop a toast).
It is copied, not symlinked — atelier rewrites this exact path when you hit
Save, using write-temp-then-rename(2), and rename replaces a symlink with
a regular file instead of following it. A symlink would silently detach on your
first save. Consequently pi-toolkit's install.sh only seeds the file when it is
absent: once you have saved your own preferences, image upgrades leave them
alone, and install.sh prints a diff hint instead of clobbering.
The seeded file uses atelier's current schema — segmentLayout with explicit
per-segment visibility, plus showSidebarAgent / showSidebarTodos /
showSidebarOnStartup. Older configs written against the pre-0.7 vocabulary
(segments, ornament, showExtensionStatuses) still load, but only through
upstream's legacy-compatibility shims — so if you are carrying one on an old
volume, expect it to keep working while missing every sidebar control added
since. sidebarPanelLayout is deliberately left unset so the panel set follows
upstream's product default as atelier adds panels; set it only if you want to
pin the order yourself.
The sidebar auto-hides below 92 terminal columns and keeps the main pane at least 64 columns wide, so a narrow terminal degrades to the plain TUI rather than a squeezed one.
docker-compose.yml — basic shape
name: pi-devbox
services:
devbox:
image: joakimp/pi-devbox:latest
container_name: pi-devbox
stdin_open: true
tty: true
# 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:
- TERM=xterm-256color
# - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot
# Secrets (GITEA_*, GITHUB_*, …) come from env_file: .env above — not
# duplicated here. An environment: entry overrides env_file and is
# interpolated from the host shell, so a stale shell export would
# silently shadow your .env. See .env.example for the full list.
volumes:
# Workspace: your host source tree
- ${WORKSPACE_PATH:-.}:/workspace
# SSH keys: read-only from host
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Per-container persistent state
- devbox-pi-config:/home/developer/.pi
- 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:/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-ssh-local:
devbox-shell-history:
devbox-zoxide:
devbox-nvim-data:
devbox-uv:
# devbox-palace:
# devbox-chroma-cache:
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
uv is installed in the base image and is the recommended way to run Python interpreters and notebooks without bloating the image:
| Goal | One-liner |
|---|---|
| IPython REPL | uv run --with ipython ipython |
| IPython + scientific stack | uv run --with ipython --with numpy --with matplotlib --with pandas ipython |
| JupyterLab (browser, port-forward needed) | uv run --with jupyterlab jupyter lab --no-browser --port 8888 |
| Marimo (modern alternative) | uv run --with marimo marimo edit --port 8889 |
For long-lived environments, prefer a project venv:
cd /workspace/myproj
uv init && uv add ipython numpy matplotlib
# then:
uv run ipython
pyproject.toml + uv.lock then capture the dependency state and
travel with the project in git.
uv only manages Python. For other languages:
| Toolchain | How to add |
|---|---|
| R | sudo apt-get install r-base-core (~200 MB) |
| GHCi (Haskell) | sudo apt-get install ghc (~700 MB) |
| Clojure | sudo apt-get install clojure (~150 MB + JVM) |
| Julia | juliaup is planned for an upcoming release |
These are runtime opt-ins and persist only in the container's writable
layer — they don't survive docker compose down -v or image updates.
tldr — first-run cache
The tldr command (provided by tealdeer) shows a "Page cache not
found" message on first invocation. To populate the cache:
tldr --update
This fetches ~1500 command pages from the tldr-pages
project and caches them in ~/.cache/tealdeer/. After that, tldr ls,
tldr docker, etc. work instantly. Re-run tldr --update periodically
to refresh.
Volumes and persistence
| Path inside container | Volume | What survives |
|---|---|---|
/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 |
~/.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.
Rebuilding ephemeral shell state at start
Two entrypoint steps put back the kind of state that the writable layer eats, so a recreate does not cost you a manual re-install:
cli_utilscommands. If acli_utilscheckout is mounted, every executable in itsbin/is symlinked into~/.local/binon start, sogit-status-alland friends are onPATHwithout a path prefix. Detection:CLI_UTILS_CONTAINER_PATH→/workspace/cli_utils→$HOME/cli_utils→/workspace/*/cli_utils. SetCLI_UTILS_LINK=0to disable. Existing real files in~/.local/binand symlinks pointing elsewhere are left alone, so a deliberate override still wins; links whose target disappeared are pruned. Do not run a host installer'sinstall.shinside the container to achieve this — it writes to the ephemeral home and dies on the next recreate.- A per-device boot hook. If
~/.config/devbox-shell/init.shexists it is run once at start (bash, never sourced, exit status ignored), with output in~/.pi/agent/devbox-init.log.~/.config/devbox-shell/is the host-owned bind-mount whosebash_aliasesis already sourced into every interactive shell, so a hook there persists across recreates with no image change. Use it for fixups that must exist before any shell — symlinks, directories, one-off migrations.
The distinction that decides which mechanism you want: ~/.local/bin is on ENV PATH, so symlinks there work in non-interactive shells too (docker exec <c> <cmd>, agent tool shells, scripts). A PATH edit in bash_aliases reaches only
interactive shells, because ~/.bashrc returns early when non-interactive —
which is also why shell functions (fzf helpers and the like) can only come
from the sourced file, never from a symlink.
MemPalace integration
MemPalace is installed in the base image and pre-warmed with the ChromaDB ONNX embedding model so first-time semantic search is instant.
The palace data lives at ~/.mempalace/palace on the host
(bind-mounted into the container). The image pins that layout with
ENV MEMPALACE_CONFIG_DIR=/home/developer/.mempalace (Dockerfile.base):
mempalace ≥ 3.10.0 would otherwise treat an empty ~/.mempalace — a freshly
mounted volume at first boot — as "no install here" and put a new palace under
~/.config/mempalace, outside anything the compose files persist. With the
variable set, first in mempalace's resolution order, the location is a contract
rather than a heuristic. This means:
- A pi running on the host and a pi running inside this container see the same palace.
- SQLite's WAL mode handles concurrent reads + single writer cleanly, so simultaneous use is safe in practice.
mempalace-session and mempalace-docs are on PATH for one-off
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.
Cross-machine agent coordination
When MEMPALACE_REMOTE_URL points at a shared palace, the container gets more
than shared search: it joins an append-only coordination log (RFC 003) that other
machines' agents can address it on — used here for design review, patch handoff
and retraction between hosts.
Two container-side settings make it work:
| Variable | Why it matters |
|---|---|
MEMPALACE_REMOTE_URL |
selects the shared palace; unset means a purely local palace, and the log then contains only this machine's own events |
MEMPALACE_PI_DEVICE |
the bridge stamps pi@<device> as the writer, which is the only way the log can tell two machines apart when both are thin clients of one palace |
So a container with no MEMPALACE_PI_DEVICE can read the log but is not
reachable on it: messages addressed to a bare pi match nobody. Set both, or
neither.
What the agent is expected to do with this lives in the mempalace skill
(~/.agents/skills/mempalace/SKILL.md) — the mailbox query at wake-up, and the
convention that a directed event with status="open" is a request owed a reply
while a * broadcast owes nothing. The mechanism side (what the bridge stamps,
and why live SSE push depends on the palace deployment's reverse proxy rather
than on this image) is documented in the toolkit's extensions/pi/README.md.
Since v1.8.9 the bridge reads the log for you. Earlier images were write-only
— they stamped provenance on the way out and never read back, so a directed ask
reached an agent only if that agent happened to run mempalace_event_list
itself. The mailbox is gated on the same two variables as the stamper, is on by
default, and derives what is owed rather than trusting status (an acked event
keeps matching a status="open" query forever, because the log is append-only):
| Variable | Default | Effect |
|---|---|---|
MEMPALACE_MAILBOX |
unset (on) | 0 disables mailbox reads entirely |
MEMPALACE_MAILBOX_POLL_MS |
300000 |
minimum gap between mid-session polls |
MEMPALACE_MAILBOX_RESURFACE_MS |
3600000 |
re-announce a still-owed ask after this long |
Delivery queues, it never interrupts: the poll runs when pi goes idle and the
message is steered into the next turn, so nothing wakes the model on inbound
fleet traffic. The practical consequence, measured on two devices: the message
appears in your session window and the agent acts on it when the next turn
starts — you are the trigger. (That describes the bridge as baked in v1.8.9,
mempalace-toolkit 5b8d78f; the mailbox's own mechanism and landmines live in
the toolkit's docs/rfc-003-coordination-log.md §7.11–§7.12, which moves ahead of
whatever this image has baked.)
Observational memory (in-session memory)
The image also bakes pi-observational-memory,
which is memory of a different kind from the palace and is easy to confuse with
it. It keeps a small branch-local ledger of observations and reflections while a
session runs, so when pi compacts the conversation the summary is a
deterministic fold of that ledger rather than a model call, and every item
keeps a 12-character id that recall(<id>) resolves back to the exact source.
In one line: observational memory keeps a session coherent; the palace keeps the fleet coherent.
It is on by default, needs no habit from you, and sends its background work to a
cheaper model than your session (Haiku while the session runs Opus, in the seeded
~/.pi/agent/settings.json). Inspect it from inside pi with /om:status and
/om:view; turn all proactive work off for one run with
PI_OBSERVATIONAL_MEMORY_PASSIVE=1 pi.
What it is for, how the lifecycle works, what it costs, every setting and its
default, and how it differs from MemPalace:
docs/observational-memory.md.
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/byentrypoint-user.shon 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 user override is never clobbered. Precedence against a mountedskillsetrepo is per-skill, not blanket — see Skillset repo below. The bundledpi-devbox-environmentskill 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.mdtells every pi session to read~/.agents/skills/pi-extensions/SKILL.mdat start (to fix fork/recall under-utilisation). That pointer would dangle in a container started without the privateskillsetrepo, so the image also bakes fallback copies ofpi-extensionsandmempalace. Whether a mounted skillset overrides them depends on who owns the skill (see Skillset repo):mempalaceis skillset-owned, so the live clone wins;pi-extensionsis owned by its package repo, so the baked copy keeps winning — the skillset's copy of it is a downstream duplicate that can lag. Thepi-extensionsskill is layered: a committed snapshot inrootfs/is the floor, andDockerfile.variantcopies the canonical, package-owned copy from the pinnedpi-extensionsclone (/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.mempalaceis snapshot-only (its consumer skill has no public package home), and because pi-toolkit'sAGENTS.mdhas 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. Seerootfs/usr/local/share/pi-devbox/skills/VENDORED.md. - Skillset repo (optional). If a
skillsetrepo is mounted (at$HOME/skillsetor/workspace/skillset, or viaSKILLSET_CONTAINER_PATH),deploy-skills.shsymlinks its skills in too. Image-baked skills are classified as foreign-links by its--prune-stalepass and left untouched — which through v1.8.4 meant the baked copy always won, so an edit pushed to a skillset-owned skill was invisible until the next image build. Since v1.8.5devbox-skill-reconcileruns right after the deploy and repoints the links for skills the skillset owns, listed in/usr/local/share/pi-devbox/skills/skillset-owned.txt(today:mempalace). Effective precedence, highest first: user override (a real directory, or a symlink pointing outside the baked tree) → live skillset clone (owned names only) → baked snapshot (everything else, and every skill when no skillset is mounted). Check withreadlink -f ~/.agents/skills/<skill>.
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:
ControlMaster auto
ControlPath /tmp/sshcm/%r@%h:%p
ControlPersist 10m
The socket directory /tmp/sshcm/ is created mode 700 on every
container start (per-container, tmpfs-friendly). Multiple ssh calls
to the same host within 10 minutes reuse the master TCP flow —
important on residential ISPs with CGNAT per-destination flow caps
(~4 flows on most European broadband; symptoms are
kex_exchange_identification: Connection closed by remote host on
the 5th+ concurrent ssh).
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.
macOS-only keywords in a shared ~/.ssh/config
The same ~/.ssh/config is read by macOS ssh and by the Linux OpenSSH inside
the container (the sidecar Includes it). macOS-only keywords are fatal
there, not ignored — a single UseKeychain yes in a Host * block takes down
every ssh call in the container:
/home/developer/.ssh/config: line 2: Bad configuration option: usekeychain
/home/developer/.ssh/config: terminating, 1 bad configuration options
That breaks dssh/dscp, pi --ssh, scp, and anything that shells out to
ssh (including CI/deploy helpers), while the host keeps working perfectly — so
it presents as a container regression rather than a host config error. Guard the
keyword on the host, before it is used:
Host *
+ IgnoreUnknown UseKeychain
UseKeychain yes
AddKeysToAgent yes
IgnoreUnknown is understood by both implementations: macOS still honours
UseKeychain, Linux skips it. Also keep such a Host * block below any
Include that must come first — OrbStack's own Include ~/.orbstack/ssh/config
says so in a comment, and a Host * block above it silently violates that.
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>— thessh-controlmasterextension detects an unwritable systemControlPathand falls back to its own writable/tmp/pi-cm-<pid>.sockmaster (its command-line-o ControlPathoverrides the user's path); the remote-pwdprobe uses-o ControlPath=noneso it cannot fail on the read-only socket dir.ssh -F ~/.ssh-local/config/dssh/dscp—setup-lan-access.shredirectsControlPathinto the writable~/.ssh-local/cmfor every host (the sidecar is rendered on all host OSes). To name LAN peers that should jump via the host, addProxyJump hostoverrides in the host-owned~/.config/devbox-shell/ssh-lan.conf(see Naming LAN peers) rather than the read-only~/.ssh/config. If the peer also rejects the host's key — the usual case, since host keys are normally passphrase-protected and the container has no Keychain or agent — see Giving the container its own key for a peer.
tmux and 0-indexed sessions
The image installs /etc/tmux.conf with:
set -g base-index 0
set -g pane-base-index 0
This is the default tmux indexing. It's baked here because pi-studio
(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
If you use Bedrock as pi's LLM provider:
- Configure SSO on the host:
aws configure sso - Bind-mount
~/.aws:/home/developer/.aws:ro - Set
AWS_PROFILEandAWS_REGIONin.env - Inside the container:
aws sso loginif needed; pi picks up the profile via the env vars.
The pi-toolkit AWS env loader (in ~/.pi/agent/) prepares Bedrock
inference-profile model IDs (with eu. / us. prefixes) automatically.
Build pipeline
pi-devbox is built from this repo's CI in two phases:
- Base (
Dockerfile.base) — producesjoakimp/pi-devbox:base-<hash>where<hash>is content-addressed overDockerfile.base,rootfs/, andentrypoint*.sh. Rebuilt only when these change. - Variant (
Dockerfile.variant) —FROM ${BASE_IMAGE}and adds the pi install (+ pi-studio whenINSTALL_STUDIO=true). The:latest/vX.Y.Zand:latest-studio/vX.Y.Z-studiotags are produced from this layer. The studio variant builds via independentsmoke-studio+build-variant-studioCI jobs that gate only the-studiotags.
Tag naming:
| Tag | Stage |
|---|---|
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 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_ATELIER_REPO |
https://github.com/michaelmjhhhh/pi-atelier.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:
# 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:
docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq .
org.opencontainers.image.{version,revision,created} plus
se.jordbo.pi-devbox.*-ref and se.jordbo.pi-devbox.*-version record the
intended pi and mempalace versions and companion refs (mempalace-version is
set in Dockerfile.base and inherited, so it names the pin of the base the
image actually built on). The on-disk /etc/pi-devbox/build-manifest.json records ground
truth — the actual checked-out commit of each /opt clone, the live
pi --version, and (from v1.8.6) the live mempalace --version of the
installed palace core — so a tag is reconstructable after CI logs rotate:
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
Inside a running container, pi-devbox-version wraps that manifest into a
human-readable summary — no need to remember the file path or pipe it
through jq yourself:
$ pi-devbox-version
pi-devbox v1.8.14
built: 2026-09-08T21:54:07Z (source 361babd4fd61)
pi: 0.85.1
palace: 3.9.0
components:
pi-toolkit: adfb553f5c8a
pi-extensions: 2610545c83bb
pi-fork: e69725c39603
pi-observational-memory: ce9fc982b3a2
pi-atelier: 734258bbcb62
mempalace-toolkit: e45f6b430181
pi-studio: e04fc7aa3275
skills:
credential-incident-response baked
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
pi-devbox-environment baked
pi-extensions baked
It also flags live drift — if pi --version no longer matches what was
baked at build time (e.g. something on a persisted volume shadowed the
image's binary), the pi: line calls that out instead of silently trusting
the manifest. --json dumps the raw manifest for scripting; --quiet gives
a one-line release_tag (source_revision) form. It also prints once,
automatically, at container start (from entrypoint-user.sh, before the
rest of the setup output) — so you see which build you're in without
asking. Exits 1 with a short notice on images built before this file
existed, rather than failing silently.
Troubleshooting
Image grew unexpectedly
docker history joakimp/pi-devbox:latest shows per-layer sizes. The
biggest layers are typically the apt block (~600 MB), pi npm install
(~330 MB), MemPalace + ChromaDB (~315 MB), AWS CLI (~270 MB), Node.js
(~200 MB).
pi can't reach LAN peers on macOS
The LAN-access helper (/usr/local/lib/pi-devbox/setup-lan-access.sh)
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
Any option can be set here, not just ProxyJump: the file is Included
before ~/.ssh/config and ssh takes the first value it sees for each
option, so whatever you put here wins while everything you omit is inherited
from the matching block in your real ~/.ssh/config. Peer names stay out of the
published image (they are a fact about your 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).
Once the file exists it is re-read on every connection, so edits take effect
immediately — no container or session restart. Creating it for the first time
does need one restart, because setup-lan-access.sh only emits the
Include ~/.config/devbox-shell/ssh-lan.conf line when the file is already
readable at container start (if [ -r "$SSH_LAN_CONF" ]). Until then ssh never
looks at it — which reads exactly like "my override is being ignored".
Giving the container its own key for a peer
ProxyJump fixes routing; it does not fix authentication, and inheriting
the host's IdentityFile usually fails inside the container:
- Host keys are commonly passphrase-protected, and that passphrase is unlocked
by the macOS Keychain or a running
ssh-agent. The container has neither, so the key can never be decrypted —Permission denied (publickey)even though the identicalssh peerworks in a host terminal. ~/.sshis mounted read-only, so you can neither drop a container-usable key in there nor edit~/.ssh/configfrom inside.
The answer is a container-only keypair in ~/.ssh-local/ — the named volume
devbox-ssh-local, so it survives docker compose up -d --force-recreate —
plus an IdentityFile override in the host-owned ssh-lan.conf. Note that
nothing is baked into the published image: that volume is created on your
machine at runtime, so no private key ever ships to Docker Hub, and a fresh pull
elsewhere generates its own. (Every key below is a throwaway example.)
1. In the container — generate a passphraseless key (there is no agent to unlock a protected one):
ssh-keygen -t ed25519 -N '' -C "devbox-$(hostname)" \
-f ~/.ssh-local/mypeer_devbox_ed25519
cat ~/.ssh-local/mypeer_devbox_ed25519.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
2. On the peer — append that public key to ~/.ssh/authorized_keys of
the account you will log in as (the User from step 3), narrowly authorized
rather than bare:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'KEY'
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
KEY
chmod 600 ~/.ssh/authorized_keys
Both lines are safe on a peer that is already set up: mkdir -p is a no-op
when the directory exists, the chmods only tighten, and appending never
touches keys already listed. Use >>, never > — one stray truncation
revokes every other key on that account. The options prefix must sit on the
same physical line as the key, comma-separated with no spaces: a paste
that wrapped is the likeliest reason a key that looks right is refused.
ssh-copy-id cannot add that prefix, so append by hand (or let it copy the
bare key and edit the line afterwards). If authentication still fails with no
clear reason, suspect permissions — sshd's StrictModes silently ignores
authorized_keys when the home directory, ~/.ssh or the file itself is
group- or world-writable, and says why only in the peer's own log
(journalctl -u ssh, /var/log/auth.log).
restrict disables pty, agent/X11 and port forwarding; append
port-forwarding and permitopen="127.0.0.1:<port>" after it if you need one
specific tunnel. from= must list the host's addresses, not the
container's: container egress is NAT'd through the host, so the peer sees the
host's LAN address (confirm with echo $SSH_CLIENT on first login). List every
network the host roams — e.g. both home WLAN subnets plus its VPN address —
because a from= mismatch is indistinguishable from a wrong key in the error
message.
3. On the host — point the peer at that key in
~/.config/devbox-shell/ssh-lan.conf:
Host mypeer mypeer.home.arpa
HostName 192.168.1.142
User myuser
IdentityFile ~/.ssh-local/mypeer_devbox_ed25519
IdentitiesOnly yes
# ProxyJump host # only if the container cannot reach the peer directly
That path exists only inside containers, which is why it belongs here rather
than in the shared ~/.ssh/config.
4. First time only — restart the container so the Include is emitted (see
above), then verify with the master socket bypassed, so a warm connection cannot
fake a pass:
ssh -F ~/.ssh-local/config -o ControlPath=none mypeer 'echo $SSH_CLIENT'
Use one key per machine (devbox-mbp, devbox-studio, …) so a single
authorized_keys line can be revoked without locking out the others.
Smoke-testing a local build
./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:
./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
./scripts/recreate-sanity-check.sh --expected-version 1.0.0 # assert the pi coding agent version
Those are two different versions, and the flags are not interchangeable:
--expected-image-version takes the pi-devbox release tag (v optional),
--expected-version takes pi --version. Hand one the other's value and it
says so by name instead of reporting a mismatch against the wrong component.
With neither flag, both values are read from the image's own build manifest
(/etc/pi-devbox/build-manifest.json): the live pi version is asserted against
the one recorded at build time — which catches a stale pi in the
~/.pi/npm-global volume shadowing the baked one — and the release tag is
reported informationally.
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:
- Major — architectural changes.
v1.0.0is the first decoupled release (independent of opencode-devbox). - Minor — new variants, significant base additions.
- Patch — pi version bumps, smaller fixes.
The pi --version inside the image is asserted by smoke tests to match the
version CI resolved (since v1.7.0, the pin below), so drift between what was
intended and what actually got baked is caught at CI time rather than on a
user's pull.
Version pins: pi, pi-atelier, mempalace
Three components are pinned to an exact version in the repo instead of being
resolved to latest at build time:
| Component | Pin | Where |
|---|---|---|
| pi | 1.0.0 |
ARG PI_VERSION — Dockerfile.variant |
| pi-obsmem | 731c3d49288580f4d79cabdbfbc0d16b34db0f41 |
ARG PI_OBSMEM_REF — Dockerfile.variant |
| pi-atelier | v0.13.0 |
ARG PI_ATELIER_REF — Dockerfile.variant |
| mempalace | 3.10.0 |
ARG MEMPALACE_VERSION — Dockerfile.base |
The objective is not to freeze versions. Bumping is routine — usually one line plus a changelog note. The objective is that adopting a new upstream version is a deliberate, reviewable act, not a side effect of whatever happened to be published the morning CI ran. Each of these has already drawn blood:
- pi — a minor release can move the private TUI/renderer internals that
pi-atelier wraps, or the session
.jsonlformatpi-session-repairparses. - pi-atelier — 0.6.0/0.7.0 hang pi 0.84 at startup, burning CPU with no
error (fixed in 0.7.1/0.7.2). Its
peerDependenciesstill say>=0.80.7, so nothing in the npm metadata expresses the real floor. - mempalace — an unpinned install once swept in the broken
diary_writeMCP tool schema of 3.3.x/3.4.0, which is why that pin's comment requires a tool-schema review before every bump.
CI enforces this rather than trusting it:
resolve-versionsreads the pins out of the Dockerfiles — single source of truth, so a localdocker buildand a CI release ship the same versions — and fails the build if a pin is not concrete, not a semver tag, or not actually published on npm.- When npm has a newer pi than the pin, CI emits a
::warning::naming it. That warning is the prompt to audit and bump; it never adopts the version. smoke-test.shasserts the image'spi --versionequals the pin, and separately asserts the pairing rule pi ≥ 0.84 ⇒ pi-atelier ≥ 0.7.1, so a bad combination fails the build instead of publishing a TUI that never starts.
To bump pi: read the upstream CHANGELOG for every intervening version (TUI/theme
API, session format, extension loader, Node engine floor), re-check pi-atelier's
CHANGELOG for the pi version it claims to track, then edit the one ARG line and
record what you checked in CHANGELOG.md.
If you previously hand-installed pi-atelier
A hand-installed pi install npm:pi-atelier lands in ~/.pi/npm-global, which
is on the devbox-pi-config volume — so it outlives image upgrades and stays
at whatever version you installed, unpinned and unaudited. Since the image now
vendors an audited pi-atelier at /opt/pi-atelier, the entrypoint removes a
lingering npm:pi-atelier entry from packages[] (after backing
settings.json up to settings.json.bak.atelier.<timestamp>) and registers the
pinned /opt copy instead. Nothing else in your settings is touched, and the
npm-global copy itself is left on disk — only the registration changes.
This matters more than it sounds: leaving a 0.6.x npm copy registered alongside pi 0.84 is precisely the combination that hangs at startup.
To opt out of pi-atelier entirely, set DEVBOX_ATELIER=0 in .env. The
entrypoint then removes any pi-atelier entry from packages[] on start. That
switch lives in the entrypoint — not in a pi command — deliberately: this
component's failure mode is "pi will not start", which you cannot repair with
pi uninstall.
Acknowledgements
pi-devbox was originally a thin re-brand of the pi-only variant of
opencode-devbox.
It was decoupled at v1.0.0 so it could evolve at its own pace, with
self-contained docs and a focused, pi-centric image. Significant base
infrastructure (the SSH ControlMaster setup, MemPalace integration,
the entrypoint UID/GID dance) was adopted from there.
The pi coding-agent itself is @earendil-works/pi-coding-agent.
License
MIT — see LICENSE. This covers the repository's own contents
(Dockerfiles, entrypoint scripts, rootfs/ seeds, CI, docs). The published
images bundle third-party software under their own licenses; see
THIRD_PARTY.md.