3d46c6615e
Lint workflows / actionlint (push) Successful in 21s
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.
781 lines
31 KiB
Markdown
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
|