Files
pi-devbox/README.md
T
pi 3d46c6615e
Lint workflows / actionlint (push) Successful in 21s
fix(base): default pandoc typst template font so PDF export works without -V mainfont
pandoc's bundled typst template defaults the document font to an empty
tuple (font: ()), so a naked `pandoc --pdf-engine=typst` fails with
"font fallback list must not be empty". Patch the template default to
Libertinus Serif (typst's own bundled default) at build time so PDF
export works out of the box. Document usage in README and note the fix
in CHANGELOG (Unreleased). Base-affecting.
2026-07-11 20:55:50 +02:00

781 lines
31 KiB
Markdown

# pi-devbox
A self-contained Docker image for running [pi](https://pi.dev) — 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 template
- `pi-extensions` — TypeScript extensions for pi (preview, MCP bridges,
mempalace integration, etc.)
- `pi-fork` — the `fork` tool for spawning sub-agents
- `pi-observational-memory` — the `recall` tool for session compaction
### 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`.
### Document and image tooling
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
- `graphviz``dot` rendering for diagram pipelines
- `imagemagick` — image conversion / resizing (invoked as `magick`)
### Language toolchains
- `python3` + `python3-venv` + `python3-pip` (system Python)
- `uv` + `uvx` — fast Python package manager (preferred over pip/venv)
- `nodejs` (v22) + `npm`
- `gcc`, `g++`, `make` — C/C++ build tools
- `rustup-init` — Rust toolchain installer (toolchains opt-in at runtime)
- Optional `INSTALL_GO=true` build arg for Go
For Python REPLs and notebooks beyond the system interpreter, see the
[uv-driven REPL recipes](#uv-driven-repl-recipes) section.
### Cloud + secrets
- AWS CLI v2 — for SSO + Bedrock auth
- `gitea-mcp` — MCP server for Gitea API
- `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) 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 `~/.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
### 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
```bash
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:
```bash
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
```bash
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](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 an upcoming minor release:
- *(shipped in Unreleased/base)* **PDF export from Studio/pandoc** now works:
the base image ships **`typst`** as the PDF engine (`pandoc --pdf-engine=typst`),
a single ~30 MB static binary — 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](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`) uses **`typst`**, shipped in
> the base image as the pandoc PDF engine (`pandoc --pdf-engine=typst`). For
> LaTeX-exact output you can install `texlive-xetex` on 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:
```bash
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):
```bash
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*:
```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
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:
```bash
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:
```bash
tldr --update
```
This fetches ~1500 command pages from the [tldr-pages](https://tldr.sh)
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.
## 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). 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.
## 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:
```
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.
### 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:
```
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:
1. Configure SSO on the host: `aws configure sso`
2. Bind-mount `~/.aws:/home/developer/.aws:ro`
3. Set `AWS_PROFILE` and `AWS_REGION` in `.env`
4. Inside the container: `aws sso login` if 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:
1. **Base** (`Dockerfile.base`) — produces `joakimp/pi-devbox:base-<hash>`
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 (+ 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:
| 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_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
`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
```
`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:
- **Major** — architectural changes. `v1.0.0` is 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 release tag's pi component, so version drift between the
image and the tag is caught at CI time.
## Acknowledgements
pi-devbox was originally a thin re-brand of the `pi-only` variant of
[opencode-devbox](https://gitea.jordbo.se/joakimp/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](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
## License
MIT