Closes the blind spot check 9 (cb6d9e5) named: no label recorded the palace pin, so a MEMPALACE_VERSION bump — the one component whose skew against the shared central palace is fleet-wide — could ship without a CHANGELOG line. In Dockerfile.base, not Dockerfile.variant, deliberately: the value sits next to the ARG that defines it (a copy in the variant is one more pin able to drift); labels are inherited by every image built FROM the base, so no build-arg to plumb through the variant's four call sites; and inheritance means the label states the pin of the base the image ACTUALLY built on, which is the question when base-decide cache-hits an older base. Both mechanisms measured on the published v1.9.2 config blob rather than assumed: maintainer + image.source (set only in Dockerfile.base) are present on the variant image, and pi-version=0.85.1 is an ARG expanded inside a LABEL. Intent, like every se.jordbo.pi-devbox.* label; the manifest's mempalace_version (read from the installed binary) stays the ground truth, and smoke-test.sh now asserts label == installed core — the one way they diverge is a base built with INSTALL_MEMPALACE=false or an off-pin install, both invisible to a label-only check. check-doc-drift check 9 gains the component (literal, against ARG MEMPALACE_VERSION in Dockerfile.base); the label-key rule generalises to "names ending in -version are the label itself". Until a release carries the label it reports a counted SKIP, not OK — measured: "v1.9.2 carries no se.jordbo.pi-devbox.mempalace-version label", summary says 1 SKIPPED. Costs nothing extra: this Unreleased already forces a base rebuild (50153e6rootfs/ skill floor). check-base-hash unchanged (no new *_REF).
22 KiB
AGENTS.md — pi-devbox
Self-contained Docker image for the pi coding-agent. Decoupled from
opencode-devbox at v1.0.0 (2026-06-09); previously pi-devbox was a thin
re-brand of opencode-devbox's pi-only variant.
Repository layout
Dockerfile.base— multi-arch base layer with system packages, GitHub-binary tools (fzf, eza, zoxide, neovim, bat, gosu, gitleaks, git-lfs, uv, gitea-mcp, tealdeer), AWS CLI v2, mempalace + toolkit, Node.js, Python toolchain, locales, ssh ControlMaster defaults, and/etc/tmux.confwith 0-indexed sessions.Dockerfile.variant—FROM base-<hash>, adds pi + companions (pi-toolkit,pi-extensions,pi-fork,pi-observational-memory) and, whenINSTALL_STUDIO=true, vendorspi-studioto/opt/pi-studio(-studiovariant). Also appends the pi-devbox managed block frompi-global-AGENTS.append.mdonto pi-toolkit'spi-global-AGENTS.md(the single global instruction slot pi loads) so containers proactively load the bakedpi-devbox-environmentskill. Idempotent via a marker grep. After the pinned clones it also refreshes the vendoredpi-extensionsfallback skill by copying/opt/pi-extensions/skill/over the committedrootfs/snapshot (Option 1 over Option 2 — seeskills/VENDORED.md).entrypoint.sh— UID/GID alignment as root, then drops todeveloper.entrypoint-user.sh— per-container start: prints thepi-devbox-versionbanner first (which build/commit is running, from the manifest below), then SSH ControlMaster socket dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install, optionalstudio-exposebridge (whenSTUDIO_EXPOSE=1), image-baked skills symlink-in, skillset deploy.rootfs/— files baked into the image (bash aliases, inputrc, setup-lan-access.sh,studio-exposehelper,pi-devbox-version— wraps/etc/pi-devbox/build-manifest.jsoninto a human-readable summary + live drift check, see README “Build provenance”). Alsousr/local/share/pi-devbox/skills/<name>/SKILL.md— image-baked agent skills (the repo-authoredpi-devbox-environment, plus vendored fallback copies ofpi-extensionsandmempalace— seeskills/VENDORED.md) symlinked into~/.agents/skills/by the entrypoint, available with or without a mounted skillset — plususr/local/share/pi-devbox/pi-global-AGENTS.append.md(the global-AGENTS pointer concatenated inDockerfile.variant).scripts/smoke-test.sh— sanity checks run by CI before pushing to Hub..gitea/workflows/docker-publish.yml— two-phase CI (base-decide → build-base → smoke → build-variant → promote-base-latest → update-description). The-studiovariant adds independentsmoke-studio+build-variant-studiojobs that gate only the-studiotags (never the core:latestrelease).
Versioning scheme
- Tags follow semver. v1.0.0 is the first decoupled release; future
minor bumps add variants (
-studio,-studio-tex) or significant base additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow pi npm version updates and small fixes. - Docker Hub tags:
joakimp/pi-devbox:vX.Y.Z+joakimp/pi-devbox:latest- (since v1.1.0)
joakimp/pi-devbox:vX.Y.Z-studio+joakimp/pi-devbox:latest-studio. Internal tags:joakimp/pi-devbox:base-<hash>(content-addressed) +joakimp/pi-devbox:base-latest(alias of most recent base).
- (since v1.1.0)
Release-day checklist
-
Confirm
pi --versionresolves from npm to the expected version (curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version). Check release notes at https://github.com/earendil-works/pi/releases for the upstream changelog to include inCHANGELOG.md. -
Refresh the vendored mempalace skill snapshot if the skillset moved:
scripts/vendor-mempalace-skill.sh --check(reads a real skillset clone, writes nothing). Three exit codes, not two — a stale-but-truthful record is not a release blocker, so don't treat any non-zero exit as "must refresh" without reading which one it was:- 0 — the record is truthful. This includes stale-but-truthful
(upstream has moved past the recorded ref, or the local clone has
uncommitted changes) — a
NOTICEis printed, but nothing is lying. Skipping the refresh in this case is the legitimate, sanctioned outcome — every enrolled host reads its own live skillset clone, so the baked copy is only a no-mount fallback. What is not legitimate is skipping it silently: the drift is visible here, inpi-devbox-version, and in the manifest, so decide rather than forget. - 1 — a confirmed problem: the vendored bytes provably do NOT match the file at the recorded ref (a lying record), or the recorded ref doesn't even resolve to that path in this clone. Refresh.
- 2 — cannot determine (the recorded ref itself isn't resolvable in
this clone — commonly a shallow checkout missing history). Fetch full
history and re-check before deciding; don't refresh blind.
Refresh with
scripts/vendor-mempalace-skill.sh, which rewrites the file and the ARG together so they cannot drift apart, and refuses (exit 1) rather than silently rewinding provenance if the skillset clone's HEAD is behind the already-recorded ref (detached HEAD, older checkout) — pass--forceonly if that rewind is genuinely intended. Two consequences to accept deliberately on an actual refresh: the snapshot is hashed intobase_tag, so it costs a base rebuild (~67 min); and if the section the phrase canary names has changed, re-pin it inscripts/smoke-test.sh.
- 0 — the record is truthful. This includes stale-but-truthful
(upstream has moved past the recorded ref, or the local clone has
uncommitted changes) — a
-
Update the docs this release makes stale — BEFORE you tag. Rename
CHANGELOG.md's## Unreleasedto## vX.Y.Z — YYYY-MM-DD(em dash, as every prior release heading uses), then run the gate:bash scripts/check-doc-drift.sh # 0 in sync / 1 drift / 2 cannot runIt compares README.md's version-pin table against the ARGs it names, and DOCKER_HUB.md's Node claim against
ARG NODE_VERSION, plus Hub's 25 000-char limit, unsubstituted{{PLACEHOLDERS}}, and staleUnreleasedpointers in user-facing docs. With network it also checks DOCKER_HUB.md's size claims against Hub's measured sizes (check 8) and — check 9 — that every component the next build would bake differently from the last published release is named in the CHANGELOG above that release's heading: it reads these.jordbo.pi-devbox.*-reflabels off the published image andgit ls-remotes each floating*_REF. A red check 9 means an upstream (pi-toolkit, pi-extensions, mempalace-toolkit, pi-fork, pi-observational-memory, pi-studio) moved and no entry names the new SHA; the failure prints the compare URL; aPI_VERSIONorMEMPALACE_VERSIONbump is caught the same way via thepi-version/mempalace-versionlabels. Name the 7-char SHA (or version) where you describe the change — that is what the old "Dependency audit" tables recorded by hand, now required.Why before and not after:
docker-publish.ymlrunsactions/checkout@v4with noref:, so every job readsgithub.ref— the tag. A doc fix pushed tomainafter tagging does not reach the release, and forDOCKER_HUB.mdit does not reach the published Hub page either, becauseupdate-descriptionPOSTs that file as Docker Hub'sfull_descriptionfrom the tag's tree. Getting it in afterwards means re-pointing the tag, which is its own hazard (v1.8.14 went601fc98→361babdand broke deploy verification untilgit fetch --tags --force).The gate is deliberately narrow — it only checks claims verifiable from files in this repo. Still eyeball, because these are NOT gated:
- counts and sizes (
~1.1 GB, "Nmempalace_*tools", "7 extensions") — they need a running image; assert them inscripts/smoke-test.shinstead - feature prose that quietly became false, e.g. a "Planned for an upcoming release" section describing something that already shipped
Dockerfile.base's# BASE_REBUILD_DATE:marker. Ungated on purpose:base_taghashes Dockerfile.base's content, comments included, so demanding it be current would force a ~60 min base rebuild on a release that touched no base files. Fix it when the base is already rebuilding — then it is free.
Measured cost of skipping this, 2026-09-10 (v1.9.0): five stale claims, one of them published. README's pin table was wrong on all three rows, and DOCKER_HUB.md — untouched for eight releases — still said Node v22 while the image shipped Node 24.
- counts and sizes (
-
Verify
docker compose upworks locally with the currentlatestimage if you're upgrading users from a previous version. Then run the post-recreate sanity check inside the running container to confirm persisted volumes survived and the pi runtime wiring re-deployed (not just that the container booted):docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-image-version X.Y.Z(or justpi-devbox-sanity --expected-image-version X.Y.Zifcli_utils/binis on PATH). This is the runtime peer of the build-timesmoke-test.shgate.X.Y.Zhere is the pi-devbox release tag you are shipping (e.g.1.8.9), which is what the rest of this checklist means byvX.Y.Z.--expected-image-versionis the flag that asserts it. There is also an--expected-version, and it means something else — the pi coding agent version (e.g.0.84.3, theARG PI_VERSIONpin). Handing the release tag to that one used to report "pi version mismatch: expected 1.8.8, got 0.84.3", i.e. a red on the final gate of the release accusing the wrong component; it now tells you to use--expected-image-versioninstead, and the reverse mix-up is caught too. Both flags are optional — with neither, the live pi version is asserted against the version recorded in the image's own build manifest (which catches a stalepiin the~/.pi/npm-globalvolume shadowing the baked one) and the image tag is reported informationally. -
Push tag:
git tag vX.Y.Z && git push origin vX.Y.Z. -
Watch CI: smoke job builds amd64 only and asserts size + extensions + pi version + new-base-tooling presence. Variant build is multi-arch (amd64 + arm64) only after smoke passes. A tag push fires only
docker-publish.yml—lint.ymlis scoped tobranches: ['**'], which excludes tag refs on purpose (the tagged tree was already linted when the commit hitmain, and a fast lint run sorting above the slow publish run made releases look finished before anything shipped). Verified on v1.8.4:refs/tags/v1.8.4produced run 571 (publish) and nothing else. Still filter discovery onhead_shaand the workflowpath— see Gitea API access below — because that guard costs nothing and a future workflow added onv*would silently reintroduce the ambiguity. -
Verify the Hub tags appear (latest + vX.Y.Z, the
-studiopair, plus base-latest if the base was rebuilt this run). -
Revoke any short-lived Gitea PAT used during the release at
gitea.jordbo.se/user/settings/applications. N/A if you used theGITEA_ACCESS_TOKENenv var instead (see Gitea API access below) — its lifecycle is managed host-side, nothing to revoke.
Verifying this repo's reality from inside a container
Most work on this repo happens inside a pi-devbox container, inspecting a host or a peer over SSH. That setup manufactures convincing false negatives, so when you are about to report that something is absent, unreachable, or not running, suspect your own command first. Recurring instances:
dockeris not on the host's non-interactive SSHPATH.ssh mac 'docker ps'says command not found on a host that plainly runs Docker; use/usr/local/bin/docker(orcommand -v dockerfirst). Every step in the Release-day checklist that inspects a running container hits this.- Don't
| head -Na search whose answer you don't already know. The host's~/.ssh/configis ~500 lines; ahead -20"proved" a peer absent that was defined at line 454. - The deployment compose file is not this repo's.
docker-compose.ymlhere is a template pinning:latest; a real host runs its own per-machine file (find it withdocker inspect <container> --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}'). Recreating from the repo copy can silently move a host off:latest-studioonto:latest. - A live SSH ControlMaster hides remote auth changes — after editing a
peer's
authorized_keys, prove access with-o ControlPath=none -o ControlMaster=no, or the breakage surfaces in a later session instead.
Depth and further mechanisms: the repo-authored pi-devbox-environment skill
(rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md) §2
and §3 — that file is the one an agent actually loads mid-session, whereas this
AGENTS.md is only auto-read when the cwd is this repo.
Gitea API access (env token)
GITEA_ACCESS_TOKEN + GITEA_HOST are passed into the container from the
host .env via docker-compose.yml (${GITEA_ACCESS_TOKEN:-} /
${GITEA_HOST:-}), primarily to enable the gitea-mcp server. They are
not baked into the image. When configured, they are also available for
any direct Gitea API interaction from inside the container — inspecting
CI runs, checking published tags, listing commits — e.g.
curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20".
Prefer this over a short-lived PAT file when the env token is present (the
ci-release-watcher skill auto-detects it). Public-repo GET listings work
unauthenticated too, so the token matters mainly for private repos or
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
revoke after use. Never echo the token value (including into logs).
Gotcha — a tag push fires EVERY workflow whose triggers match the tag ref.
lint.yml uses a bare push: trigger, so a release tag yields both a lint run
and the publish run. The listing is newest-first and lint sorts above the
publish run, so "take the first run whose path contains refs/tags/<tag>"
picks the wrong one reliably, not occasionally. Real listing for v1.6.4:
id=531 #104 lint.yml@refs/tags/v1.6.4 <- wrong; sorts first
id=530 #103 docker-publish.yml@refs/tags/v1.6.4 <- the release build
id=529 #102 lint.yml@refs/heads/main <- same commit, linted on push
Lint goes green in minutes while the image is still building, so watching it makes a release look finished when nothing has been published yet.
Gotcha — the jobs endpoint takes the internal id, NOT the run_number the
UI shows as #104. The two diverge widely, and GET .../actions/runs/<run_number>/jobs does not error — it silently returns a
different run's jobs. Always read id from the run listing:
# Which runs did this tag/commit trigger? Filter on head_sha; never trust
# ordering or run numbering. limit=20, not 5 — with two runs per push the
# publish run falls off a 5-item window fast.
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20" \
| jq --arg sha "$(git rev-list -n1 vX.Y.Z)" \
'.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, status, conclusion}'
# pick the id whose .path starts with docker-publish.yml, then:
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs/<id>/jobs" \
| jq '.jobs[] | {name, status, conclusion}'
Watcher config for this repo (ci-release-watcher skill, hub-only shape —
pi-devbox has no downstream host to deploy to):
EXPECT_WORKFLOW=docker-publish.yml— the skill'spreflight_run()aborts at startup if the run id belongs to lint instead.EXPECTED_FRESH_TAGS='vX.Y.Z latest vX.Y.Z-studio latest-studio'EXPECTED_EXISTS_TAGS='base-latest'— existence only: it is content-addressed and legitimately keeps its old timestamp when the base is a cache hit.CRITICAL_JOBS='build-variant build-variant-studio'— job names are matched exactly (critical.issubset(succeeded)), so the studio variant must be listed explicitly; the skill's default omits it. Leavepromote-base-latestout: it legitimately skips on a base cache hit, which would misclassify a good run.update-descriptionis the cosmetic post-publish job.
Cache-hit footgun (must-know)
PI_VERSION defaults to latest in Dockerfile.variant but CI must
resolve it to a concrete version string before passing as a build-arg.
Otherwise the build-arg string is byte-identical across releases →
identical layer hash → registry buildcache silently reuses the old
layer. resolve-versions job in the workflow handles this.
Discovered in pi-devbox 2026-05-23 (every release v0.74.0..v0.75.5
shipped the same image bytes); preventatively fixed for PI_VERSION +
PI_FORK_REF + PI_OBSMEM_REF.
Smoke-test gate
scripts/smoke-test.sh runs amd64-only against a freshly-built variant
image. Verifies binaries, repo clones, runtime deployment (waits for
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
the parallel-build-load race documented in opencode-devbox c6f9d11
2026-06-08), build-time leftovers (see below), and image size threshold
(3800 MB in SIZE_THRESHOLD_MB; revisit after a few releases as actuals
settle — this doc said 3500 until 2026-09-11, after the bar had already
moved twice).
If smoke fails on size threshold but build is otherwise fine: bump
SIZE_THRESHOLD_MB in scripts/smoke-test.sh in a follow-up commit and
re-run. The threshold exists to catch runaway growth (an accidental
texlive bake-in, a forgotten chrome dependency), not to block ordinary
upstream bumps.
The size gate is not a substitute for naming the residue. It carries
~225 MB of deliberate margin, so v1.9.1 shipped +131 MB of pure build
residue — 110 MB of it npm's own download cache under /root/.npm, the
rest foreign platform packages — and stayed green. Four named assertions
now cover that ground: no foreign npm-11 platform packages beyond the
host arch (@esbuild/*, @mariozechner/clipboard-*), no /root/.npm in
the image, and — because the prune's real risk is removing something
needed, not size — esbuild must compile TS and clipboard must load its
native binding at every install site.
Two failure shapes to copy from those, both of which bit here:
test ! -d /root/.npmon mode-700/rootpasses for a permission error, so the cache assertion refuses to run as non-root. Watch for this in any assertion about a path you may not be allowed to read.node -e 'require("esbuild")'resolves by walking up from the CURRENT DIRECTORY, so it fails withMODULE_NOT_FOUNDfrom/workspaceon a perfectly healthy image (esbuild is nested inside the pi trees;NODE_PATHis unset). Always path-qualify:require("<abs>/esbuild"). A runbook shipped the bare form with "if this fails, revert the release" attached, and it duly went red for the wrong reason.
Build pipeline notes
- Two-phase: base + variant. Base is rebuilt only when
Dockerfile.base,rootfs/, orentrypoint*.shchange (CI computes a content hash and probes Hub for an existingbase-<hash>tag). base-latestalias is promoted frombase-<hash>viacrane copy(manifest copy, no rebuild) only when the base actually changed.docker buildx build --pushretry: 3 attempts with backoff for transient Hub blips. Deterministic failures fail all 3 and the job fails as expected.- Registry buildcache disabled: buildkit's cache-export hits HTTP 400 on Hub CDN since ~2026-05-23. Image push works fine; we pay the full base build on Dockerfile.base change, but base tags are content- addressed so unchanged bases short-circuit at the probe step.
Decoupling history (briefly)
Pre-v1.0.0 pi-devbox was FROM joakimp/pi-devbox:base-pi-only, where
base-pi-only was a tag built by opencode-devbox CI (with
INSTALL_OPENCODE=false in their variant Dockerfile) and pushed under
the pi-devbox repo as an internal building-block tag. This setup
required rebuilding opencode-devbox before pi-devbox could be tagged
and meant pi-devbox docs needed cross-referencing into opencode-devbox.
v1.0.0 brings pi install logic into this repo, drops the cross-repo
dependency, and the base-pi-only* tags from opencode-devbox become
deprecated artifacts (to be removed in opencode-devbox v2.0.0).
What we DON'T install (and why)
- No texlive (~600 MB–1 GB). PDF export from pandoc / pi-studio works
out of the box via
typst(~30 MB static binary), which the base ships as the pandoc PDF engine (pandoc --pdf-engine=typst) — small enough to live in base rather than a dedicated:latest-studio-texvariant. We don't bake in a full TeX Live: it's heavy and typst covers the common Markdown→PDF case. Users needing LaTeX-exact output can install the higher-fidelity fallback on demand:sudo apt-get install texlive-xetex texlive-latex-recommended(thenpandoc --pdf-engine=xelatex). - pi-studio ships in the
:latest-studiovariant (since v1.1.0), vendored to/opt/pi-studioand registered at container start viapi install /opt/pi-studio(see Dockerfile.variantINSTALL_STUDIO). The default:latestimage stays studio-free. Note: pi-studio binds127.0.0.1inside the container, so browser access needs host networking or the bundledstudio-exposebridge (socat; auto-starts whenSTUDIO_EXPOSE=1) — see README "Using pi-studio". - No Julia/R/GHCi/Clojure runtimes. Use
uv run --with Xfor Python REPLs;apt installother-language runtimes ad-hoc per container if needed.
Backward compatibility
- The host
~/.mempalacebind-mount path is unchanged. - Volume names (
devbox-pi-config,devbox-ssh-local,devbox-shell-history,devbox-zoxide,devbox-nvim-data,devbox-uv; optionaldevbox-palace,devbox-chroma-cache) are unchanged. ~/.pi/agent/layout inside the container is unchanged; existing named volumes work without recreation.- The
:latestandvX.Y.ZHub tags continue to point at a "base + pi" image. Same tag, same shape, just built differently.