26 Commits

Author SHA1 Message Date
pi c1154f1fa6 v1.0.0: decouple from opencode-devbox
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Successful in 45m47s
Publish Docker Image / smoke (push) Successful in 8m18s
Publish Docker Image / build-variant (push) Successful in 22m41s
Publish Docker Image / update-description (push) Failing after 9s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Self-contained build chain — own Dockerfile.base + Dockerfile.variant
+ entrypoint scripts + rootfs + CI pipeline. Previously v0.79.0 and
earlier were thin re-brands of opencode-devbox's pi-only variant
(joakimp/pi-devbox:base-pi-only built by opencode-devbox CI).

Architectural changes:
- Replace 5-line Dockerfile shim with full base+variant pair.
- Adapt CI workflow from opencode-devbox/docker-publish-split.yml,
  simplified to a single variant. Includes content-addressed base hash,
  PI_VERSION concrete-resolution to defeat registry-buildcache footgun,
  crane-based base-latest promotion, and the c6f9d11 smoke-test gate.
- pi-devbox releases no longer require rebuilding opencode-devbox first.

Base image additions:
- pandoc, graphviz, imagemagick, yq — broadly useful, ~260 MB total.
- tldr (tealdeer) — Rust port replaces Node tldr global, saves 135 MB.
- /etc/tmux.conf with base-index 0 + pane-base-index 0 — required for
  the planned :latest-studio variant; pi-studio hard-codes :0.0 target.

Smoke test:
- New checks for pandoc, graphviz, imagemagick, yq, tldr, tmux config,
  /tmp/sshcm directory.
- Image-size measurement now sums docker history layers (the prior
  inspect --format='{{.Size}}' returned only the variant-unique layer
  with the new base/variant split, understating by 2+ GB).
- Threshold 2850 → 3500 MB to absorb base additions + arch margin.

Image size:
- Local arm64 build: 3.20 GB. ~390 MB up from prior pi-only equivalent.
- Will tighten threshold once amd64 actuals settle in CI.

Pre-1.0 history preserved at tag pre-v1.0.0-decouple-backup.

Future work:
- v1.1.0: :latest-studio variant (adds pi-studio).
- v1.2.0: :latest-studio-tex variant (adds texlive-xetex for PDF).
- opencode-devbox v2.0.0 will retire INSTALL_PI / pi-only paths.
2026-06-10 01:14:07 +02:00
pi 36afd3c716 Release v0.79.0 — pi 0.78.1 -> 0.79.0
Publish Docker Image / smoke (push) Successful in 2m56s
Publish Docker Image / publish (push) Successful in 10m38s
Publish Docker Image / update-description (push) Successful in 6s
First build on pi 0.79.0. Built FROM the republished base-pi-only from
opencode-devbox v1.16.2 (carries pi 0.79.0). Bump smoke size threshold
2750 -> 2850 MB in lockstep with opencode-devbox's pi-only variant.

Promote CHANGELOG Unreleased -> v0.79.0.
2026-06-08 22:00:24 +02:00
pi 2ab03aaa6f docs: complete CHANGELOG + promote Unreleased -> v0.78.1
Publish Docker Image / smoke (push) Successful in 1m42s
Publish Docker Image / publish (push) Successful in 10m35s
Publish Docker Image / update-description (push) Successful in 8s
Add the two missing doc entries (the ~/.config/devbox-shell compose mount,
3bfbafa; and DEVBOX_HOST_ALIAS in .env.example, 45f4488) and promote Unreleased
-> v0.78.1 (2026-06-04). v0.78.1 is a real pi version bump (0.78.0 -> 0.78.1);
builds FROM the republished base-pi-only carrying pi 0.78.1 from opencode-devbox
v1.15.13e. Docs only.
2026-06-04 22:45:28 +02:00
pi 2e86e5a3f3 compose: persist LAN jump key (devbox-ssh-local volume) + docs
Persist ~/.ssh-local so the generated LAN-jump key survives container
recreation; authorize it on the host once per machine. Adds the volume
to the compose template and documents it in the README volumes table.
LAN-access mechanism/script changes are inherited from base-pi-only
(opencode-devbox).
2026-06-04 14:34:05 +02:00
pi 45f4488764 env.example: document DEVBOX_HOST_ALIAS (self-contained getting-started) 2026-06-04 13:36:41 +02:00
pi 3bfbafad9e compose: document optional ~/.config/devbox-shell mount (LAN ssh-lan.conf + bash_aliases bridge) 2026-06-04 13:34:15 +02:00
pi d9a538c405 release: v0.78.0c — inherit LAN-access fixes from rebuilt base-pi-only
Publish Docker Image / smoke (push) Successful in 1m45s
Publish Docker Image / publish (push) Successful in 10m16s
Publish Docker Image / update-description (push) Successful in 5s
opencode-devbox v1.15.13d published the rebuilt base-pi-only (digest 83b45335)
with the fixed setup-lan-access.sh (Include scope + ControlPath) and the new
ssh-lan.conf / RFC1918 autojump. Tagging now to build the thin pi-devbox on it.
2026-06-04 13:18:26 +02:00
pi 08bb0c520e docs: LAN-access ssh-lan.conf + DEVBOX_LAN_AUTOJUMP_PRIVATE (inherited from base)
setup-lan-access.sh fixes (Include scope, ControlPath) + ssh-lan.conf and
RFC1918 autojump flow in via FROM base-pi-only. Documents the knob and new
host-owned config. Tag v0.78.0c AFTER opencode-devbox v1.15.13d publishes the
rebuilt base-pi-only, so it doesn't build on the stale base.
2026-06-04 00:52:58 +02:00
pi e996b01542 docs: promote CHANGELOG Unreleased -> v0.78.0b
Publish Docker Image / smoke (push) Successful in 2m57s
Publish Docker Image / publish (push) Successful in 10m40s
Publish Docker Image / update-description (push) Successful in 9s
2026-06-03 22:59:37 +02:00
pi 03629cdac7 refactor: build FROM joakimp/pi-devbox:base-pi-only (Option B)
The pi-only building block now lives in this repo as the internal
base-pi-only tag (produced by opencode-devbox CI from Dockerfile.variant,
INSTALL_OPENCODE=false) instead of opencode-devbox:latest-pi-only — so an
'opencode-devbox' tag never ships without opencode.

- Dockerfile: BASE_IMAGE default joakimp/opencode-devbox:latest-pi-only
  -> joakimp/pi-devbox:base-pi-only.
- Updated README, AGENTS, DOCKER_HUB, docker-compose, CHANGELOG.
- Single source of truth unchanged (opencode-devbox/Dockerfile.variant);
  publish ordering + EXPECTED_PI_VERSION smoke guard unchanged.
2026-06-03 17:04:21 +02:00
pi 1d1283f942 refactor: FROM opencode-devbox:latest-pi-only (lean, no opencode)
Re-point the re-brand at the new pi-only variant instead of with-pi, so
pi-devbox stays a lean pi-focused image (no opencode) while the pi install
logic still lives in one place upstream. This keeps pi-devbox meaningfully
distinct from opencode-devbox:latest-with-pi.

- Dockerfile: BASE_IMAGE default -> joakimp/opencode-devbox:latest-pi-only.
- smoke-test.sh: size threshold 2900 -> 2750 MB (pi-only = with-pi minus
  opencode's ~145 MB binary).
- Docs (README/AGENTS/DOCKER_HUB/CHANGELOG/docker-compose): drop the
  'also contains opencode' notes; describe pi-only basis and the distinction
  from with-pi.

Publish ordering unchanged: release opencode-devbox first so latest-pi-only
carries the target pi version, then tag here (smoke asserts pi --version).
2026-06-03 16:14:05 +02:00
pi c139be326f refactor: re-brand the opencode-devbox with-pi variant (single source of truth)
pi-devbox no longer installs pi itself. The Dockerfile is now a thin
FROM joakimp/opencode-devbox:latest-with-pi (overridable via BASE_IMAGE),
inheriting pi + pi-toolkit + pi-extensions + pi-fork (fork) +
pi-observational-memory (recall) + the LAN-access helper + all base tooling
from the single source of truth. Eliminates the install-logic duplication
that drifted against opencode-devbox/Dockerfile.variant (decision #3).

Consequences (documented in CHANGELOG/AGENTS):
- The image now ALSO contains opencode (with-pi has INSTALL_OPENCODE=true).
  A leaner pi-only image would need a dedicated pi-only variant upstream.
- Publish ordering: release opencode-devbox first so latest-with-pi carries
  the target pi version, THEN tag this repo. The smoke test asserts
  pi --version matches the tag (EXPECTED_PI_VERSION) and fails loudly if the
  base is stale — turning the version coupling into an enforced ordering guard.

CI: drop PI_VERSION build-arg (Dockerfile installs nothing); keep tag->version
resolution to feed the smoke base-freshness guard. Smoke adds fork/recall
clone + node_modules + settings.json registration checks; size threshold
2200 -> 2900 MB (now tracks with-pi). Docs updated across README, AGENTS,
DOCKER_HUB, .env.example, docker-compose.
2026-06-03 15:51:41 +02:00
pi 1587a84579 Cut v0.78.0 — pi 0.77.0→0.78.0
Publish Docker Image / smoke (push) Successful in 2m24s
Publish Docker Image / publish (push) Successful in 13m13s
Publish Docker Image / update-description (push) Successful in 7s
2026-05-31 22:26:21 +02:00
pi 32df96f0ea Cut v0.77.0 — pi 0.76.0→0.77.0
Publish Docker Image / smoke (push) Successful in 2m25s
Publish Docker Image / publish (push) Successful in 13m10s
Publish Docker Image / update-description (push) Successful in 10s
First container build on pi 0.77 line (published upstream 2026-05-28).
Built against unchanged joakimp/opencode-devbox:base-latest (same as
v0.76.0 — SSH-CM, gitleaks, git-crypt all carry forward).

Notable pi 0.77.0 upstream:
- Claude Opus 4.8 support
- --exclude-tools / -xt for selective tool disablement
- Headless Codex subscription login (device-code auth)
- Streaming-aware extension input (InputEvent.streamingBehavior)
- Long bugfix list (startup timing, signal handling, terminal
  protocol detection, Windows MSYS2 fixes, provider metadata
  cleanups, session disposal abort, etc).

Also folds the previously-Unreleased CI retry-wrapper change
(2d39766) into this release block. Second publish exercising the
cache-export-disabled workflow; first to exercise the 3-attempt
retry wrapper through the publish path.

See CHANGELOG v0.77.0 for full notes.
2026-05-29 09:07:47 +02:00
pi 2d397663d5 ci: workflow-level 3-attempt retry around buildx build --push
Belt-and-braces against transient registry-1.docker.io blips (rate
limits, brief 5xx, CDN flap). Replaces docker/build-push-action@v7 with
a shell: bash step that runs docker buildx build --push in a for-loop
with backoff (15s, 30s).

Does NOT mask deterministic failures: a true regression (e.g. the
cache-export 400 we hit 2026-05-23..28) fails all 3 attempts
identically and the job still fails by design. Orthogonal layer to
both cache-export disablement and the ci-release-watcher skill's
transient-rerun heuristic.

No image-side change.
2026-05-28 16:32:34 +02:00
joakimp e6a21f36f1 Cut v0.76.0 — pi 0.76.0 + inherit SSH-CM/gitleaks from base-latest
Publish Docker Image / smoke (push) Successful in 2m18s
Publish Docker Image / publish (push) Successful in 14m11s
Publish Docker Image / update-description (push) Successful in 6s
pi 0.75.5 → 0.76.0 (published upstream 2026-05-27 20:03 UTC). First
pi-devbox release built against opencode-devbox base-latest carrying the
SSH ControlMaster bake-in (commit 668592d) and gitleaks (73a7f96) — both
inherited transparently with no Dockerfile change here. PI_VERSION is
resolved from the git tag by the workflow (v0.75.5b cache-hit fix), so
no Dockerfile default bump needed.

Workflow change: registry cache-export removed from publish step. buildkit
mode=max cache-export to registry-1.docker.io reproducibly returns HTTP 400
(Hub-CDN protocol mismatch with buildx 0.34.x, surfaced ~2026-05-23).
Diagnosed during opencode-devbox v1.15.12 manual publish: image push works,
only --cache-to fails. Pi-devbox would hit the same regression on the next
tag push without this fix. See opencode-devbox CHANGELOG v1.15.12 for the
full root-cause analysis. Pi-devbox is single-stage with a tiny diff (npm
install pi only) on top of base-latest, so builds are fast even uncached.
2026-05-28 10:43:52 +00:00
joakimp 9b305c9f7e Doc: note SSH ControlMaster fix arrives via opencode-devbox base
Symmetric with the gitleaks/git-crypt inherit-note already present.
Cross-references opencode-devbox commit 668592d (Unreleased), which
bakes /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf with a
writable /tmp/sshcm ControlPath. pi-devbox picks this up automatically
on its next build against base-latest; no Dockerfile change here.

Documents the symptom users see today inside pi-devbox <= v0.75.5b
(unix_listener Read-only file system on \~/.ssh/cm) and the fact
that pi --ssh user@host inside the container is currently silently
broken until the cascade lands.
2026-05-26 18:29:00 +00:00
joakimp 5d9208c547 Doc: note gitleaks + git-crypt arrive via opencode-devbox base
No Dockerfile install change here — pi-devbox FROMs joakimp/opencode-
devbox:base-latest which gained gitleaks (and explicit acknowledgment
of git-crypt) in opencode-devbox commit adding both to the base layer.
The next pi-devbox release built against a fresh base-latest digest
inherits both with zero work on this side.

CHANGES

Dockerfile — comment block at top updated to name git-crypt + gitleaks
in the 'inherited from base' toolset enumeration. Helps future
readers: one less reason to think 'I need to install gitleaks here'.

CHANGELOG.md — new Unreleased entry pointing at the opencode-devbox
base-side change for full detail. Will be promoted whenever the next
pi-devbox release ships (probably alongside the next pi npm bump past
0.75.5).

Holding off on tagging — pi upstream still at 0.75.5, baseline release
v0.75.5b is already current with that. Will ride along with next pi
bump.
2026-05-24 15:49:38 +00:00
joakimp 34cae2a1d2 Cut v0.75.5b — fix cache-hit silent same-bytes regression
Publish Docker Image / smoke (push) Successful in 2m18s
Publish Docker Image / publish (push) Successful in 12m59s
Publish Docker Image / update-description (push) Successful in 11s
ALL FOUR releases v0.74.0 -> v0.75.5 had been shipping the same image
bytes due to a Docker layer-cache hit on the bare 'npm install -g
@earendil-works/pi-coding-agent' command (when PI_VERSION=latest).
The command string is identical across builds, so the layer-hash is
identical, so registry buildcache (cache-from/cache-to) silently
reuses the layer from whatever pi version was current when the cache
was first populated.

Verification: docker manifest inspect joakimp/pi-devbox:vX.Y.Z showed
identical SHA256 digests on both linux/amd64 and linux/arm64 for
v0.74.0, v0.75.3, v0.75.4, v0.75.5. Users on :latest were getting
whatever pi version was baked into the v0.74.0 build.

DISCOVERED 2026-05-23 by user trying to update pi-devbox on MBP-M1
and seeing pi 0.74.0 reported despite pulling v0.75.5.

CHANGES

.gitea/workflows/docker-publish.yml — both smoke and publish jobs
get a new 'Resolve PI_VERSION from tag' step that strips the leading
'v' and any trailing letter suffix from github.ref_name. Result is
passed as a build-arg to docker/build-push-action so the npm install
layer's hash includes the concrete version, forcing cache miss when
pi bumps.

scripts/smoke-test.sh — new run_expect helper that asserts pi
--version contains the EXPECTED_PI_VERSION env var. Smoke job sets
this from the resolve step output. Would have caught this regression
on v0.75.3.

Dockerfile — comment block above ARG PI_VERSION=latest documenting
the cache-hit footgun. The 'if latest' branch in the install RUN is
preserved for local dev convenience but never fires in CI now.

AGENTS.md — new convention bullet explaining the cache-hit class of
bug and noting the latent same-bug in opencode-devbox's with-pi
variants (currently masked by OPENCODE_VERSION bumps; will manifest
when cutting a vN.N.Nb-style opencode-version-unchanged release that
only bumps pi).

CHANGELOG.md — full entry under v0.75.5b describing the recovery,
the silent-failure mechanism, and the verification steps.

NO IMAGE-CONTENT CHANGES vs v0.75.5 INTENT. This build produces the
actual pi 0.75.5 image content that v0.75.5 was supposed to ship.

NEXT FOLLOWUP (parked, not in this commit)

opencode-devbox should get the same workflow change for its
build-variant-with-pi and build-variant-omos-with-pi jobs. Currently
masked because every release also bumps OPENCODE_VERSION which
invalidates the cache, but that masking would fail on a pi-only bump
release.
2026-05-23 22:10:08 +02:00
joakimp dff3092338 AGENTS: note pi changelog source is npm-tarball CHANGELOG.md
Publish Docker Image / publish (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / smoke (push) Failing after 14m8s
Companion to opencode-devbox's 'Upstream sources' section. Pi's npm
package ships a rich CHANGELOG.md with New Features / Added / Changed
/ Fixed sections — but the npm registry metadata ('npm view') doesn't
include the changelog body. Surface the 'npm pack + tar' recipe in
the release-day checklist so future-pi (and human-pi) doesn't try to
derive notes from npm view alone.

Doc-only, no CI implications.
2026-05-23 19:26:48 +02:00
joakimp c7f7f97754 Cut v0.75.5 — pi 0.75.4 -> 0.75.5
Publish Docker Image / smoke (push) Successful in 2m40s
Publish Docker Image / publish (push) Failing after 14m9s
Publish Docker Image / update-description (push) Has been cancelled
One upstream patch release, two days after v0.75.4. PI_VERSION=latest
in Dockerfile resolves to 0.75.5 at build time, so no Dockerfile change
is needed; just a CHANGELOG promote.

Notable upstream changes (read tool card cleanup, faster Windows file
tools, more reliable pi update, custom adaptive-thinking knob, several
bash/Bedrock fixes) — see CHANGELOG.md for the full list.

Cache hit expected on opencode-devbox:base-latest (base-35ee5fe7861a).
Tagged together with opencode-devbox v1.15.10 — both releases go
through the queued CI runner overnight.
2026-05-23 19:14:54 +02:00
joakimp b6cc2c748b Cut v0.75.4 — pi 0.75.3 -> 0.75.4
Publish Docker Image / smoke (push) Successful in 2m25s
Publish Docker Image / publish (push) Successful in 11m30s
Publish Docker Image / update-description (push) Successful in 14s
One upstream patch release. PI_VERSION=latest in Dockerfile resolves
to 0.75.4 at build time, so no Dockerfile change is needed; just a
CHANGELOG promote.

Tagged speculatively before opencode-devbox v1.15.6's omos-with-pi
smoke completes — pi 0.75.4 is a single patch on top of 0.75.3, low
risk on its own. If opencode-devbox v1.15.6 surfaces a pi 0.75.4
problem in the omos-with-pi smoke (3700 MB threshold trip, etc.),
both releases would fail in symmetric ways and recovery would be a
v0.75.4b/v1.15.6b pair. Same recovery muscle as v1.15.4 -> v1.15.4b
last week.

Built on opencode-devbox:base-latest, cache-hit on base-35ee5fe7861a
since v1.14.50b — base unchanged across both bumps.
2026-05-21 00:16:43 +02:00
joakimp ae6253ab23 AGENTS.md: documentation-drift sweep as explicit pre-commit step
Companion to the same addition in the cloud-init and ansible repos.
Caught real drift in those repos in a recent session only because
the user explicitly asked. Codify the sweep with concrete, repo-
specific drift hotspots rather than a vague 'watch for drift' rule
that gets ignored.

Each AGENTS.md addition lists the doc files most likely to fall
behind code changes here, plus a quick-triage one-liner using
'git diff --name-only HEAD | xargs grep -l ...' so the rule is
actionable not aspirational.
2026-05-20 23:11:59 +02:00
joakimp da21206e6e v0.75.3: bump pi 0.74.0 -> 0.75.3
Publish Docker Image / smoke (push) Successful in 4m47s
Publish Docker Image / publish (push) Successful in 11m25s
Publish Docker Image / update-description (push) Successful in 12s
pi @earendil-works/pi-coding-agent@0.74.0 -> 0.75.3 (one upstream minor
+ three patch releases since the initial pi-devbox release on 2026-05-14).

Validated: opencode-devbox v1.15.4b's smoke-with-pi and smoke-omos-with-pi
both passed with pi 0.75.3 baked in. Node v22.22.2 is comfortably above
pi's new minimum requirement of 22.19.0.

Built on joakimp/opencode-devbox:base-latest (cache hit on
base-35ee5fe7861a from 2026-05-14). PI_VERSION=latest in Dockerfile
resolves to 0.75.3 at build time. Image-side unchanged from v0.74.0
beyond the pi npm version.
2026-05-18 23:32:18 +02:00
joakimp 973c2efd5c Expand README + tweak DOCKER_HUB.md for users not cloning the repo
README rewrite:
- Two quick-start paths: 'no git clone' (curl docker-compose.yml +
  .env.example) and 'with git clone' for hackers/forkers
- New 'Authentication' section with subsections per provider
  (Anthropic, OpenAI, Gemini, AWS Bedrock static, AWS Bedrock SSO).
  AWS SSO path documents the ~/.aws bind-mount.
- Persistent state expanded: 5-row volume table + optional volumes
  table. Annotated what survives what.
- Configuration reference: full .env table.
- Versioning, building from source (with build args table),
  troubleshooting FAQ, related projects, license.
- 11 kB total — comprehensive but readable.

DOCKER_HUB.md tweaks:
- Quick-start now has a 'no git clone' path (curl two files), pointing
  users at the gitea README for the full setup guide. The git-clone
  path was overkill for the 90% case (just want to docker run).
- Explicit link to gitea README at the end of the quick-start block.
2026-05-15 17:58:06 +02:00
joakimp 5d472bd41f DOCKER_HUB.md: expand from stub to full Hub description
Replace the 1-line placeholder with a proper Hub README:
image variants table, quick start (docker run + docker compose),
inherited-from-base + added-by-pi-devbox feature lists, versioning
scheme, persistent volumes table, user-installed pi packages note,
source links.

Already PATCH'd live on Docker Hub manually — this commit keeps the
in-repo file in sync so the next tag-triggered update-description job
won't roll it back to the stub.
2026-05-15 08:47:23 +02:00
16 changed files with 2549 additions and 158 deletions
+21
View File
@@ -9,6 +9,27 @@ WORKSPACE_PATH=~/projects
# Path to SSH keys on host # Path to SSH keys on host
SSH_KEY_PATH=~/.ssh SSH_KEY_PATH=~/.ssh
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint
# then sets up the host as an SSH jump (use the `dssh` alias). Reach the host
# with `dssh host`; for named LAN peers put `ProxyJump host` overrides in a
# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than
# editing ~/.ssh/config. On native Linux Docker the LAN is reachable directly
# and this is a no-op.
# See the opencode-devbox README for the full walkthrough.
#
# DEVBOX_LAN_ACCESS: auto (default) | jump | off
# DEVBOX_LAN_ACCESS=auto
# HOST_SSH_USER: your username on the host (required for the jump). On first
# start the entrypoint prints the public key to authorize on the host.
# HOST_SSH_USER=
# DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal).
# DEVBOX_HOST_ALIAS=host.docker.internal
# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump any private (RFC1918) IP through
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
# ── Git Configuration ──────────────────────────────────────────────── # ── Git Configuration ────────────────────────────────────────────────
GIT_USER_NAME= GIT_USER_NAME=
GIT_USER_EMAIL= GIT_USER_EMAIL=
+331 -30
View File
@@ -1,9 +1,38 @@
name: Publish Docker Image name: Publish Docker Image
# Two-phase split-base build pipeline for pi-devbox.
# Adapted from opencode-devbox/.gitea/workflows/docker-publish-split.yml
# (commit before v1.16.2). pi-devbox v1.0.0 introduces a self-contained
# build chain — base + variant Dockerfiles in this repo — so this
# workflow no longer depends on opencode-devbox CI.
#
# Pipeline shape:
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
# + entrypoints; probe Docker Hub for existing tag.
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
# to commit SHAs (defeats registry-buildcache
# cache-hit footgun on byte-identical build args).
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
# 4. smoke amd64-only build of the variant FROMing the base
# tag; runs scripts/smoke-test.sh.
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
# 7. update-description patch Docker Hub description.
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.'
required: false
default: ''
promote_latest:
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
required: false
default: 'false'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,16 +41,190 @@ concurrency:
env: env:
BUILDKIT_PROGRESS: plain BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
jobs: jobs:
# ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
base_tag: ${{ steps.compute.outputs.base_tag }}
need_build: ${{ steps.probe.outputs.need_build }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
# Hash inputs that determine the base image's contents.
# Order is fixed via `find -print0 | sort -z` for reproducibility.
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
# locally but still picked up by `find rootfs -type f` on a clean CI
# checkout. Exclude them defensively.
HASH=$(
{
cat Dockerfile.base
find rootfs -type f \
! -path '*/__pycache__/*' \
! -name '*.pyc' \
! -name '.DS_Store' \
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
)
BASE_TAG="base-${HASH}"
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
echo "Computed base tag: ${BASE_TAG}"
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Probe Docker Hub for existing base tag
id: probe
run: |
set +e
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
> /dev/null 2>&1
PROBE_RC=$?
set -e
if [ "${PROBE_RC}" = "0" ]; then
echo "need_build=false" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
else
echo "need_build=true" >> "$GITHUB_OUTPUT"
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 1b: resolve floating versions to concrete refs ────────────
# Without this, when PI_VERSION defaults to 'latest', the build-arg string
# is byte-identical across builds → identical layer hash → registry
# buildcache silently reuses the layer from whatever pi version was
# current when the cache was first populated. Same class of bug as
# pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b 2026-05-23).
resolve-versions:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
steps:
- name: Resolve pi version + fork/obsmem refs
id: resolve
run: |
set -eu
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm
# is not reliably on PATH in act_runner job containers.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
# Resolve pi-fork / pi-observational-memory git refs to commit
# SHAs so the build-arg string changes whenever upstream moves.
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
[ -n "$FORK_REF" ] || FORK_REF=master
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base (multi-arch) — with retry
shell: bash
env:
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
run: |
set -euo pipefail
# 3-attempt retry around `docker buildx build --push` for transient
# registry-1.docker.io blips. Does NOT mask deterministic failures.
# Registry cache disabled: buildkit cache-export hits HTTP 400 from
# Hub CDN since ~2026-05-23. Image push itself works; we pay full
# base build on Dockerfile.base change, but the base tag is content-
# addressed so unchanged bases short-circuit at the probe step.
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.base \
--push \
--tag "${BASE_TAG_FULL}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
done
echo "==> All 3 build+push attempts failed"
exit 1
# ── Phase 3: amd64 smoke (gates the multi-arch publish) ─────────────
smoke: smoke:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf - name: Force IPv4 for Docker Hub
- run: | run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \ rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \ /usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \ /usr/local/lib/android /usr/local/share/powershell \
@@ -29,24 +232,34 @@ jobs:
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true docker system prune -af --volumes || true
docker builder prune -af || true docker builder prune -af || true
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host} with: {driver-opts: network=host}
- uses: docker/login-action@v3
- name: Build (amd64, load to local daemon) with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build amd64 variant for smoke
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: Dockerfile.variant
platforms: linux/amd64 platforms: linux/amd64
push: false push: false
load: true load: true
tags: pi-devbox:smoke tags: pi-devbox:smoke
build-args: |
- name: Smoke test BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
- name: Smoke test (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke run: bash scripts/smoke-test.sh pi-devbox:smoke
publish: # ── Phase 4: multi-arch publish ─────────────────────────────────────
needs: smoke build-variant:
needs: [base-decide, smoke, resolve-versions]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -61,7 +274,6 @@ jobs:
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true docker system prune -af --volumes || true
docker builder prune -af || true docker builder prune -af || true
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
with: {platforms: arm64} with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
@@ -70,29 +282,103 @@ jobs:
with: with:
username: ${{ vars.DOCKERHUB_USERNAME }} username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
- name: Compute tags
id: tags id: tags
run: | run: |
VERSION="${{ github.ref_name }}" VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF" { echo "tags<<EOF"
echo "${IMAGE}:${VERSION}" echo "${IMAGE}:${VERSION}"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest" echo "${IMAGE}:latest"
fi
echo "EOF" echo "EOF"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
run: |
set -euo pipefail
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.variant \
--push \
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "PI_FORK_REF=${FORK_REF}" \
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
done
echo "==> All 3 build+push attempts failed"
exit 1
- name: Build and push (amd64 + arm64) # ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
uses: docker/build-push-action@v7 promote-base-latest:
with: needs:
context: . - base-decide
platforms: linux/amd64,linux/arm64 - build-variant
push: true # Skip on cache-hit base builds: when need_build=false, base-latest
tags: ${{ steps.tags.outputs.tags }} # already points at the same digest as base-<hash>, so the retag is
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache # a tautology and any transient failure of it is purely cosmetic.
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max # Manual workflow_dispatch with promote_latest=true overrides this
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
if: |
always() &&
needs.build-variant.result == 'success' &&
(inputs.promote_latest == 'true' ||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
# action's bootstrap script periodically rate-limits on
# api.github.com/.../releases/latest. Pinning removes the runtime
# dependency on GitHub API entirely.
- name: Install crane (pinned)
env:
CRANE_VERSION: v0.21.6
run: |
set -eux
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C /usr/local/bin crane
crane version
- name: Login (crane)
run: |
crane auth login docker.io \
-u ${{ vars.DOCKERHUB_USERNAME }} \
-p "${{ secrets.DOCKERHUB_TOKEN }}"
- name: Re-tag base-<hash> as base-latest
run: |
crane copy \
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
${{ env.IMAGE }}:base-latest
# ── Phase 6: update Hub description (only on real release runs) ────
update-description: update-description:
needs: publish needs: [build-variant]
if: |
always() &&
needs.build-variant.result == 'success' &&
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -100,12 +386,27 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Update Docker Hub description - name: Update Docker Hub description
run: | run: |
PAYLOAD=$(jq -n --rawfile desc DOCKER_HUB.md '{"full_description": $desc}') TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/auth/token" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"username\":\"${{ vars.DOCKERHUB_USERNAME }}\",\"password\":\"${{ secrets.DOCKERHUB_TOKEN }}\"}" \ -d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
| jq -r '.token') | jq -r .access_token)
curl -s -X PATCH "https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \ if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
-H "Authorization: Bearer ${TOKEN}" \ echo "::error::Failed to authenticate with Docker Hub API"
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--arg short "Self-contained Linux container for the pi coding-agent — pi + companions + MemPalace + curated dev tooling. Decoupled from opencode-devbox at v1.0.0." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "${PAYLOAD}" | jq -r '.full_description | if . then "✅ description updated (\(. | length) chars)" else "❌ update failed" end' -d @-)
if [ "$HTTP_CODE" != "200" ]; then
echo "Response body:"
cat /tmp/hub-response.txt
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Description updated."
+115 -22
View File
@@ -1,37 +1,130 @@
# AGENTS.md — pi-devbox # AGENTS.md — pi-devbox
Container image that adds pi coding-agent on top of the opencode-devbox base image. 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 ## Repository layout
- `Dockerfile` — single-stage build, `FROM opencode-devbox:base-latest`, installs pi + companion repos - `Dockerfile.base` — multi-arch base layer with system packages,
- `docker-compose.yml` — compose file for local use GitHub-binary tools (fzf, eza, zoxide, neovim, bat, gosu, gitleaks,
- `.env.example` — environment variable template git-lfs, uv, gitea-mcp, tealdeer), AWS CLI v2, mempalace + toolkit,
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Docker Hub Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
- `.gitea/workflows/docker-publish.yml` — CI pipeline: smoke amd64 → multi-arch push → update Hub description `/etc/tmux.conf` with 0-indexed sessions.
- `Dockerfile.variant``FROM base-<hash>`, adds pi + companions
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`).
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
deploy, mempalace-bridge symlink, fork/recall pi-install, skillset
deploy.
- `rootfs/` — files baked into the image (bash aliases, inputrc,
setup-lan-access.sh).
- `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).
## Versioning scheme ## Versioning scheme
- Tags follow the pi npm version: `v{pi_version}[letter]` - Tags follow semver. **v1.0.0** is the first decoupled release; future
- Bump `PI_VERSION` build-arg default in `Dockerfile` when cutting a new release minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow
- Docker Hub: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest` pi npm version updates and small fixes.
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`.
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
`joakimp/pi-devbox:base-latest` (alias of most recent base).
## Release-day checklist ## Release-day checklist
1. Bump `PI_VERSION` in `Dockerfile` (or leave as `latest` to pick up current) 1. Confirm `pi --version` resolves from npm to the expected version
2. Update `CHANGELOG.md`: promote `Unreleased``vX.Y.Z — YYYY-MM-DD` (`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
3. Add fresh `## Unreleased` section 2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
4. Commit, tag `vX.Y.Z`, push tag → CI fires automatically 3. Verify `docker compose up` works locally with the current `latest` image
if you're upgrading users from a previous version.
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes.
6. Verify the Hub tags appear (latest + vX.Y.Z, plus base-latest if the
base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at
`gitea.jordbo.se/user/settings/applications`.
## Key facts ## Cache-hit footgun (must-know)
- **Base image**: `joakimp/opencode-devbox:base-latest` — rebuilt whenever opencode-devbox cuts a new base `PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
- **pi binary**: baked at `/usr/bin/pi` (system npm prefix); `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` at runtime so user-installed pi/packages land on the named volume resolve it to a concrete version string** before passing as a build-arg.
- **Companion repos**: pi-toolkit and pi-extensions cloned to `/opt/` at build time; `entrypoint-user.sh` (inherited from base) deploys symlinks to `~/.pi/agent/` on container start Otherwise the build-arg string is byte-identical across releases →
- **MemPalace**: fully operational — inherited from base image; bridge extension deployed by entrypoint identical layer hash → registry buildcache silently reuses the old
layer. `resolve-versions` job in the workflow handles this.
## Conventions 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`.
- Do NOT call `mempalace-toolkit/install.sh` in the Dockerfile — the base entrypoint handles it ## Smoke-test gate
- `NPM_CONFIG_PREFIX=/usr` must be set per-RUN for any build-time `npm install -g` to keep baked binaries off the volume-shadowed path
- The smoke test threshold is 2200 MB — update if the image legitimately grows past it `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), and image size threshold (3500 MB; revisit after a few
releases as actuals settle).
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.
## Build pipeline notes
- **Two-phase**: base + variant. Base is rebuilt only when
`Dockerfile.base`, `rootfs/`, or `entrypoint*.sh` change (CI computes
a content hash and probes Hub for an existing `base-<hash>` tag).
- **`base-latest` alias** is promoted from `base-<hash>` via `crane copy`
(manifest copy, no rebuild) only when the base actually changed.
- **`docker buildx build --push` retry**: 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 MB1 GB). Users who need PDF export from pandoc
can install on demand: `sudo apt-get install texlive-xetex
texlive-latex-recommended`. The planned `:latest-studio-tex` variant
will bake this in.
- **No pi-studio** (yet). Coming in v1.1.0 as the `:latest-studio`
variant. v1.0.0 is intentionally scope-limited to "decouple, don't
reshape."
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
Python REPLs; `apt install` other-language runtimes ad-hoc per
container if needed.
## Backward compatibility
- The host `~/.mempalace` bind-mount path is unchanged.
- Volume names (`devbox-pi-config`, `devbox-bash-history`,
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are
unchanged.
- `~/.pi/agent/` layout inside the container is unchanged; existing
named volumes work without recreation.
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base + pi"
image. Same tag, same shape, just built differently.
+332 -1
View File
@@ -2,12 +2,343 @@
All notable changes to the pi-devbox container image. All notable changes to the pi-devbox container image.
Tags follow the pi npm version: `v{pi_version}[letter]` — bare tag for the first build on a new pi release, letter suffix (`b`, `c`, …) for container-level rebuilds on the same version. From v1.0.0 onward, tags follow semver:
- **major** — architectural changes (v1.0.0 = decoupled from opencode-devbox)
- **minor** — new variants, significant base additions
- **patch** — pi version bumps, smaller fixes
Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## Unreleased ## Unreleased
_(no changes since v1.0.0)_
## v1.0.0 — 2026-06-09
**Decoupled from opencode-devbox.** pi-devbox is now self-contained:
own `Dockerfile.base` + `Dockerfile.variant`, own CI pipeline, own
release cadence. Previously v0.79.0 and earlier were thin re-brands of
the `pi-only` variant built by opencode-devbox CI.
### Architectural
- **Self-contained build chain.** `Dockerfile.base` produces
`joakimp/pi-devbox:base-<hash>` (content-addressed); `Dockerfile.variant`
FROMs the base and adds the pi install. Replaces the prior 5-line
`Dockerfile` shim that FROMed `joakimp/pi-devbox:base-pi-only` (an
opencode-devbox CI artifact).
- **No more publish-ordering coupling.** pi-devbox releases no longer
require rebuilding opencode-devbox first.
- **Adapted from opencode-devbox** at the time of decoupling — the
apt set, ssh ControlMaster setup, MemPalace integration, entrypoint
UID/GID dance, and CI pipeline shape are all derived from there. See
Acknowledgements in README.md.
- **CI workflow** rewritten as two-phase split-base build pipeline
(mirrors opencode-devbox's `docker-publish-split.yml` shape, simplified
to a single variant). Includes `crane`-based `base-latest` promotion,
registry-buildcache footgun guard via concrete `PI_VERSION` resolution,
and the c6f9d11 smoke-test gate (waits for keybindings + mempalace.ts
+ ≥4 *.ts before sampling).
### Added (base image)
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. ~200 MB.
- **graphviz** — `dot` rendering for diagram pipelines. ~10 MB.
- **imagemagick** — image conversion (invoked as `magick`, not `convert`,
in v7+). ~50 MB.
- **yq** — YAML-aware companion to jq.
- **tldr (tealdeer)** — Rust port of tldr-pages, ~5 MB static binary.
Replaced the Node `tldr` global (which was ~140 MB).
- **`/etc/tmux.conf`** with `set -g base-index 0` + `set -g
pane-base-index 0`. Required for the planned `:latest-studio`
variant; pi-studio hard-codes its tmux send target to `:0.0`. User-
level `~/.tmux.conf` overrides still win.
### Added (smoke test)
- Asserts pandoc, graphviz, imagemagick, yq, and tldr are present.
- Asserts `/etc/tmux.conf` has the 0-indexed config baked.
- Asserts `/tmp/sshcm/` directory created mode 700 by entrypoint.
- Image-size measurement now sums `docker history` layer sizes (the
prior `image inspect --format='{{.Size}}'` approach returned only
the variant-unique layer when the base was content-addressed and
shared, understating the user-facing image size by 2+ GB).
- Size threshold raised to 3500 MB (was 2850) to cover the new base
additions plus +200 MB safety margin. Tighten in a follow-up release
once amd64 actuals settle.
### Image size
Local arm64 build of `pi-devbox-test:latest` (this branch's content):
3.20 GB. Up ~390 MB from the prior pi-only-equivalent (~2.81 GB) due
to pandoc, graphviz, imagemagick, yq, and minor expansion in pi npm
dependencies.
### Migration notes
- Existing volumes (`devbox-pi-config`, `devbox-bash-history`,
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are
unchanged in name and structure. `docker compose pull && docker
compose up -d --force-recreate` is a clean upgrade path.
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base +
pi" image. Same shape, just built differently.
- `:base-pi-only` and `:base-pi-only-vX.Y.Z` tags from prior releases
remain on Hub for now; will be deprecated when opencode-devbox
retires the pi paths in its next major release.
### Future work
- v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)).
- v1.2.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
## v0.79.0 — 2026-06-08
First build on pi **`0.79.0`** (upstream `@earendil-works/pi-coding-agent` bump
from `0.78.1`). Built `FROM` the freshly republished
`joakimp/pi-devbox:base-pi-only` from opencode-devbox `v1.16.2`, which carries
pi `0.79.0` (and picks up opencode `1.16.2` in the sibling opencode-bearing
variants, though this pi-only image has no opencode).
### Bumped: pi 0.78.1 → 0.79.0
Resolved from the tag and asserted by the smoke base-freshness guard
(`EXPECTED_PI_VERSION`). Highlights from the upstream `CHANGELOG.md`:
- **Project trust for local inputs** — pi now asks before loading project-local
settings, resources, instructions, and packages, with saved decisions and
`--approve` / `--no-approve` controls for non-interactive modes, plus a
`project_trust` extension event so global/CLI extensions can decide or defer.
- **Cache-hit visibility in the footer** — the interactive footer shows the
latest prompt cache hit rate (`CH`).
- **Richer SDK/RPC extension surfaces** — public exports now include RPC
extension UI request/response types and package asset path helpers.
- Plus a large batch of TUI and provider fixes (Kitty keyboard fallback,
prompt-history cursor placement, large-JSONL session reads, custom-provider
routing).
### Smoke size threshold 2750 → 2850 MB
Tracks opencode-devbox's `pi-only` variant, which was raised to 2850 MB in
`v1.16.2` for headroom against the pi `0.79.0` bump (and routine apt drift).
Kept in lockstep so this image's guard matches its source-of-truth variant.
## v0.78.1 — 2026-06-04
First build on pi **`0.78.1`** (upstream `@earendil-works/pi-coding-agent` bump
from `0.78.0`). Built `FROM` the freshly republished
`joakimp/pi-devbox:base-pi-only` from opencode-devbox `v1.15.13e`, which carries
pi `0.78.1` plus the LAN-jump key-persistence work and the `devbox-ssh-local`
volume ownership fix. Adds compose/env documentation in this repo.
### Added: persist the LAN-jump key + one-line authorize hint
- **compose:** persist `~/.ssh-local` via a new `devbox-ssh-local` named volume
so the generated LAN-jump key survives `docker compose up --force-recreate`.
You authorize the key on the host **once per machine** instead of after every
container update.
- **Inherited from base:** `setup-lan-access.sh` now prints a copy-paste
`echo '…' >> ~/.ssh/authorized_keys` line when it generates a new key
(published via opencode-devbox's `base-pi-only`). No helper file to locate.
### Docs: document optional host-owned config in the compose + env templates
- **compose:** added a commented-out `~/.config/devbox-shell` bind mount with a
note — the image's `~/.bash_aliases` sources
`~/.config/devbox-shell/bash_aliases` if present, and `setup-lan-access.sh`
reads `~/.config/devbox-shell/ssh-lan.conf` for named-peer `ProxyJump host`
overrides (reach LAN peers by name via `dssh <peer>`).
- **.env.example:** documented `DEVBOX_HOST_ALIAS` (host hostname to reach,
default `host.docker.internal`) so getting-started is self-contained.
Template/example comments only; no behavior change.
## v0.78.0c — 2026-06-04
### Fixed / Added (inherited from the base via `FROM`)
LAN-access improvements made in opencode-devbox's `setup-lan-access.sh` (baked
into the `base-pi-only` image, published by opencode-devbox v1.15.13d) flow
through to pi-devbox automatically — no pi-devbox source change. Built `FROM`
the rebuilt `joakimp/pi-devbox:base-pi-only` (digest `83b45335…`):
- **Fixed:** the generated `~/.ssh-local/config` had `Include ~/.ssh/config`
scoped to the `host`/`mac` block, so `dssh <peer>` by name was ignored.
- **Fixed:** read-only `~/.ssh/cm` ControlPath broke multiplexed hosts
(`pmx-jh`, `proxmox*`, …); master sockets now use the writable sidecar.
- **Added:** host-owned `~/.config/devbox-shell/ssh-lan.conf` for named-peer
`ProxyJump host` overrides (Included before `~/.ssh/config`).
- **Added:** `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — ProxyJump any RFC1918 IP through
the host for roaming laptops.
## v0.78.0b — 2026-06-03
Container-level rebuild on pi `0.78.0` (unchanged): re-brands the pi-only build
as a thin `FROM joakimp/pi-devbox:base-pi-only`, inheriting fork/recall and
host-OS-agnostic LAN access. Letter-suffix release (pi version unchanged).
### Changed: refactored to re-brand the opencode-devbox `pi-only` variant
pi-devbox no longer installs pi itself. The `Dockerfile` is now a thin
`FROM joakimp/pi-devbox:base-pi-only` (overridable via the `BASE_IMAGE`
arg), inheriting pi + pi-toolkit + pi-extensions and all base tooling from the
single source of truth. This eliminates the install-logic duplication that
used to drift against `opencode-devbox/Dockerfile.variant`.
The pi-only artifact is **built** by opencode-devbox's CI (from
`opencode-devbox/Dockerfile.variant` with `INSTALL_OPENCODE=false`) but is
**published into this repo** as the internal building-block tag
`joakimp/pi-devbox:base-pi-only` (+ `base-pi-only-vX.Y.Z`, where `vX.Y.Z` is
the opencode-devbox release version). This supersedes the brief approach of
publishing it as `opencode-devbox:latest-pi-only` — an "opencode-devbox" tag
with no opencode in it confused users. `base-pi-only` is internal; end users
pull `joakimp/pi-devbox:latest` or a `vX.Y.Z` tag.
The pi-only build uses `INSTALL_OPENCODE=false`, so this image
stays lean and pi-focused — it does **not** carry opencode, and remains
distinct from `opencode-devbox:latest-with-pi` (which has both).
### Added (inherited from the pi-only variant)
- **`fork` tool** (pi-fork) and **`recall` tool** (pi-observational-memory),
baked into `/opt` with `node_modules` and registered at runtime.
- **Host-OS-agnostic LAN access**: on VM-backed hosts (macOS OrbStack /
Docker Desktop) the entrypoint sets up the host as an SSH jump to reach LAN
peers (`dssh` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` env). No-op on
native Linux. See the opencode-devbox README for details.
### Consequences / notes
- **Publish ordering**: release opencode-devbox first so `base-pi-only`
carries the target pi version, *then* tag this repo. The smoke test asserts
`pi --version` matches the tag and fails loudly if the base is stale.
- CI no longer passes `PI_VERSION` as a build-arg (the Dockerfile installs
nothing); it still resolves the tag version to feed the smoke base-freshness
guard. Smoke size threshold 2200 → 2750 MB (now tracks the pi-only variant).
_pi version unchanged at `0.78.0` (still latest)._
## v0.78.0 — 2026-05-29
pi `0.77.0` → `0.78.0` bump (first container build on the pi 0.78 line, published upstream 2026-05-29). Built against `joakimp/opencode-devbox:base-latest` (unchanged from the v0.77.0 build).
### Bumped: pi 0.77.0 → 0.78.0
**New Features**
- **Named startup sessions** — `--name` / `-n` sets the session display name before startup across interactive, print, JSON, and RPC modes.
- **Clickable file tool paths** — built-in file tool titles render OSC 8 `file://` hyperlinks when the terminal supports them, including supported tmux clients.
**Added**
- Exported `convertToPng` for extension authors.
- Exported `parseArgs` and type `Args` for extension authors.
- Added a resume command hint when exiting interactive sessions.
- Added custom Amazon Bedrock request header support.
**Fixed**
- Fixed early interactive input typed before the prompt loop starts so it is buffered instead of dropped.
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages.
- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them.
- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines.
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
## v0.77.0 — 2026-05-29
pi `0.76.0` → `0.77.0` bump (first container build on the pi 0.77 line, published upstream 2026-05-28). Built against `joakimp/opencode-devbox:base-latest` (unchanged from the v0.76.0 build — same SSH-CM, gitleaks, git-crypt baked in).
### Bumped: pi 0.76.0 → 0.77.0
Notable upstream changes (from pi's CHANGELOG):
- **Claude Opus 4.8 support** — Anthropic Opus 4.8 model metadata + adaptive-thinking coverage updated.
- **Selective tool disablement** — `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available.
- **Headless Codex subscription login** — `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions; browser login remains the default.
- **Streaming-aware extension input** — `InputEvent.streamingBehavior` lets extensions distinguish idle prompts from mid-stream steers and queued follow-ups.
- **Bugfixes** — startup timing output excludes `createAgentSessionRuntime` work; OpenRouter DeepSeek V4 `xhigh` reasoning preserves OpenRouter's native effort; SIGTERM/SIGHUP exits run extension `session_shutdown` cleanup; keyboard protocol negotiation ignores delayed terminal responses (no false Kitty detection); Windows MSYS2 ucrt64 startup crash fixed via napi-rs 3.x clipboard addon; API-key/header config resolution treats plain strings as literals with `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` escaping; session disposal aborts in-flight agent/compaction/branch-summary/retry/bash work; `pi.getAllTools()` exposes per-tool `promptGuidelines`; OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions; Anthropic-compatible replay supports `allowEmptySignature` for providers returning empty thinking signatures; OpenAI/OpenRouter GPT-5.5 Pro thinking levels limited to supported efforts; OpenCode Go Kimi K2.6 thinking-off requests; Xiaomi Token Plan model metadata cleaned of unsupported variants; follow-up messages queued by `agent_end` extension handlers drain before idle; system prompt tool-selection guidance avoids unavailable file-exploration tools; fenced `diff` highlighting restored.
Workflow continues to derive `PI_VERSION` from the git tag (`v0.77.0` → `0.77.0`) and pass it as a build-arg per the v0.75.5b cache-hit fix; smoke test asserts `pi --version` matches.
### Inheritance from base
No base change in `joakimp/opencode-devbox:base-latest` since v0.76.0 — the v1.15.12 opencode-devbox release also reused the unchanged base. SSH ControlMaster on a writable socket path, gitleaks, and git-crypt continue to ride along from the base.
### CI
This is the second pi-devbox release exercising the cache-export-disabled workflow (after v0.76.0's clean publish on run #340) and the first to also exercise the 3-attempt retry wrapper added in 2d39766 along the publish path.
## v0.76.0 — 2026-05-28
pi `0.75.5` → `0.76.0` bump (first minor-version release on pi 0.76 line, published upstream 2026-05-27 20:03 UTC). Built against a fresh `joakimp/opencode-devbox:base-latest` which now bakes in SSH ControlMaster on a writable socket path, plus gitleaks and git-crypt — see the inherited-from-base notes below for details on each.
### Bumped: pi 0.75.5 → 0.76.0
Notable upstream changes (from pi's CHANGELOG):
- **Explicit session IDs for automation** — `--session-id <id>` lets scripts create or resume an exact project-local session.
- **RPC bash output can stay out of model context** — RPC clients can pass `excludeFromContext` to `bash` for commands whose output should not be sent with the next prompt.
- **More predictable provider retries and timeouts** — Codex WebSocket/SSE waits are bounded; `retry.provider.maxRetries` controls provider retries instead of hidden SDK defaults; SDK retries default to 0; quota/billing 429s are no longer retried behind Pi's retry handling.
- **Better terminal editing across environments** — Apple Terminal Shift+Enter detection on macOS, Windows Terminal OSC 8 hyperlink support, JetBrains truecolor with disabled OSC 8, Unicode-aware word navigation and deletion.
- **Bugfixes** — `pi update` bypasses npm/pnpm/Bun minimum-release-age gates; user-authored ordered-list markers preserved in transcripts; image attachment token estimates aligned with tool-result images; Codex Responses cache-affinity header fixed (`session-id` not `session_id`); OpenRouter/Poolside context-overflow detection; managed npm extension updates avoid peer-dependency conflicts; RpcClient handles unexpected child exits cleanly.
Workflow continues to derive `PI_VERSION` from the git tag (`v0.76.0` → `0.76.0`) and pass it as a build-arg, per the v0.75.5b cache-hit fix; smoke test asserts `pi --version` matches.
### Workflow change: registry cache-export disabled
- **`.gitea/workflows/docker-publish.yml`** — `cache-from`/`cache-to` removed from the `publish` step. buildkit's `mode=max` cache-export to `registry-1.docker.io` reproducibly returns HTTP 400 on the resumable-upload PUT, surfacing ~2026-05-23. Diagnosed during opencode-devbox v1.15.12's manual host-side publish: image push works fine, only `--cache-to` fails. See opencode-devbox CHANGELOG v1.15.12 `Unreleased` for the full root-cause analysis. The pi-devbox Dockerfile is single-stage with a tiny diff (npm install pi only) on top of `base-latest`, so builds are fast even without cache (~30-60s expected).
### Inherited from opencode-devbox base: SSH ControlMaster on a writable socket path
No Dockerfile change here — just a note that this release picks up the system-wide SSH ControlMaster default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` → `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlMaster auto`, `ControlPersist 10m`). This unblocks `ssh` and `pi --ssh user@host` from inside the container when `~/.ssh` is bind-mounted read-only from the host (the standard pi-devbox compose layout) — previously, OpenSSH's default `ControlPath` under `~/.ssh/cm/` was unwritable, so multiplexing failed with `unix_listener: cannot bind ... Read-only file system` and ssh fell back to fresh TCP connections, which on residential CGNAT manifested as banner-exchange timeouts. The fix is purely additive (per-container `/tmp/sshcm` dir, mode 700, created by entrypoint) and user `~/.ssh/config` per-host overrides still win because Debian's stock `ssh_config` sources `ssh_config.d/*.conf` before its own `Host *` block. See opencode-devbox CHANGELOG `v1.15.12` for the base-side details.
### Inherited from opencode-devbox base: gitleaks + git-crypt
No Dockerfile change here — just a note that this release includes `gitleaks` (newly added to the base) and `git-crypt` (was always installed via apt; just wasn't called out). Both are useful inside the container for repos that use a gitleaks pre-commit hook or git-crypt-encrypted canonical config and don't want host-side dependencies. See opencode-devbox CHANGELOG `v1.15.12` for the base-side details.
## v0.75.5b — 2026-05-23
Recovery release fixing a **silent cache-hit regression** discovered in the v0.75.5 image. All four releases v0.74.0 through v0.75.5 had been shipping the same image bytes because the Dockerfile's `npm install -g @earendil-works/pi-coding-agent` (bare, when `PI_VERSION=latest`) produces an identical layer-hash across builds. Combined with the registry buildcache, Docker reused the layer from whatever pi version was current when the cache was first populated.
Verification: `docker manifest inspect joakimp/pi-devbox:vX.Y.Z` showed identical SHA256 digests on both `linux/amd64` and `linux/arm64` for v0.74.0, v0.75.3, v0.75.4, v0.75.5. Users on `:latest` were getting whatever pi version was baked into the v0.74.0 build (probably 0.74.0 itself).
- **Workflow fix:** Both `smoke` and `publish` jobs now derive `PI_VERSION` from `github.ref_name` (e.g. `v0.75.5b` → `0.75.5`) and pass it as a build-arg. The Dockerfile's existing `if PI_VERSION=latest` branch never fires in CI now — always takes the `@${PI_VERSION}` branch — so the layer-hash includes the version and cache invalidates correctly.
- **Smoke test:** New `run_expect` helper asserts `pi --version` output contains `EXPECTED_PI_VERSION` (passed from the resolve step). Would have caught this regression on v0.75.3 if it had existed.
- **Dockerfile:** Comment added above `ARG PI_VERSION=latest` documenting the cache-hit footgun and pointing at the workflow's resolve step + AGENTS.md gotcha.
- **AGENTS.md:** New convention bullet explaining the cache-hit class of bug and noting the latent same-bug in opencode-devbox's `with-pi` variants (currently masked by OPENCODE_VERSION bumps).
No image-side changes vs v0.75.5 *intent* — this build will produce the actual pi 0.75.5 image content that v0.75.5 was supposed to ship.
## v0.75.5 — 2026-05-23
pi `0.75.4` → `0.75.5` bump (one upstream patch release, two days after v0.75.4).
Notable upstream changes (from pi's CHANGELOG):
- Cleaner read tool output (collapsed cards show only the read line; Ctrl+O expands).
- Faster file tools on Windows (async fs ops during streaming, image resize off the main TUI thread).
- More reliable package updates (`pi update` reconciles git-pinned refs without losing settings).
- Custom Anthropic-compatible adaptive thinking via `compat.forceAdaptiveThinking`.
- Several bash/read tool card display fixes; macOS Bun clipboard sidecar resolution; per-session OpenCode-Zen routing headers; Amazon Bedrock token cap fix.
Plus a new pi 0.74.2 rescue release advising Node 20 users to upgrade Node before going to newer Pi versions — the devbox base image runs newer Node so this doesn't affect us, but worth noting for users running pi outside the devbox.
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.5` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.5 at build time — no Dockerfile change needed).
- No image-side changes from v0.75.4 beyond the pi npm version. Built on `joakimp/opencode-devbox:base-latest` which itself is unchanged (cache-hit on `base-35ee5fe7861a` since v1.14.50b).
## v0.75.4 — 2026-05-21
pi `0.75.3` → `0.75.4` bump (one upstream patch release). Plus the AGENTS.md documentation-drift sweep clause that landed on `main` between v0.75.3 and now.
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.4` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.4 at build time — no Dockerfile change needed).
- **AGENTS.md:** documentation drift sweep as explicit pre-commit workflow step (commit `ae6253a`). Companion clause added across the wider repo set the same day.
- No image-side changes beyond the pi npm version. Built on `joakimp/opencode-devbox:base-latest` which itself is unchanged (cache-hit on `base-35ee5fe7861a` since v1.14.50b).
## v0.75.3 — 2026-05-18
pi `0.74.0` → `0.75.3` bump (one upstream minor + three patch releases since the initial pi-devbox release on 2026-05-14).
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.3` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.3 at build time).
- No image-side changes from the v0.74.0 baseline beyond the pi npm version. The pi-toolkit + pi-extensions clones, mempalace bridge symlink, and `NPM_CONFIG_PREFIX` named-volume setup all unchanged.
## v0.74.0 — 2026-05-14 ## v0.74.0 — 2026-05-14
Initial release. Initial release.
+111 -1
View File
@@ -1 +1,111 @@
pi coding-agent container — built on opencode-devbox base. Includes pi, pi-toolkit, pi-extensions, mempalace, AWS CLI, neovim, and full dev toolchain. See https://gitea.jordbo.se/joakimp/pi-devbox for full docs. # pi-devbox
A Docker container with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on top of [opencode-devbox](https://hub.docker.com/r/joakimp/opencode-devbox)'s base image. Pi gets a fully-loaded development environment in one `docker run`.
## Image variants
| Tag | Size (compressed) | What you get |
|---|---|---|
| `joakimp/pi-devbox:latest` | ~700 MB | Pi + companion repos, on top of the opencode-devbox base |
| `joakimp/pi-devbox:vX.Y.Z` | same | Pinned pi version (tracks the [pi npm package version](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) |
Multi-arch: `linux/amd64`, `linux/arm64`.
## Quick start
One-shot, no persistence:
```bash
docker run -it --rm \
-v "$PWD":/workspace \
-v "$HOME/.ssh":/home/developer/.ssh:ro \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
joakimp/pi-devbox:latest pi
```
For a fully-configured environment with persistent settings, MemPalace memory, neovim plugins, and shell history surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
```bash
mkdir -p ~/pi-devbox && cd ~/pi-devbox
curl -O https://gitea.jordbo.se/joakimp/pi-devbox/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.jordbo.se/joakimp/pi-devbox/raw/branch/main/.env.example -o .env
# Edit .env — set WORKSPACE_PATH, an LLM API key (ANTHROPIC_API_KEY,
# OPENAI_API_KEY, GEMINI_API_KEY, or AWS_*), and your git identity.
docker compose run --rm devbox pi
```
Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini, AWS Bedrock SSO + static), persistence model, configuration reference, build args, troubleshooting: **<https://gitea.jordbo.se/joakimp/pi-devbox#readme>**
## What's inside
pi-devbox is a re-brand of the **pi-only build** — it builds
`FROM joakimp/pi-devbox:base-pi-only` and adds no layers of its own. That
building-block tag is produced by opencode-devbox's CI (from
`Dockerfile.variant` with `INSTALL_OPENCODE=false`) but published here, in the
pi-devbox repo, so an opencode-devbox tag never ships without opencode.
The pi-only build is lean
and pi-focused (no opencode — use `opencode-devbox:latest-with-pi` if you want
both).
Everything below is inherited from that single source of truth.
Base tooling:
- **Debian trixie** (latest stable)
- **Node.js** (LTS), **uv** (Python tooling), **rustup** (Rust on-demand)
- **AWS CLI v2** + AWS Bedrock-ready config
- **MemPalace** + MCP server — persistent agent memory across sessions, queryable via `mempalace_*` tools inside pi
- **Gitea MCP** server
- **Dev tools**: neovim (LazyVim defaults), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make
- **Shell**: bash with history tuning, prefix-search bindings, fzf/zoxide integration
- **Host-OS-agnostic LAN access** — on VM-backed hosts (macOS OrbStack / Docker Desktop) the host is set up as an SSH jump to reach LAN peers (`dssh` alias; `DEVBOX_LAN_ACCESS`/`HOST_SSH_USER`). No-op on native Linux.
pi and companions:
- **pi** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — baked at `/usr/bin/pi`, version set by the pi-only base build
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
- **mempalace bridge** — MCP extension auto-symlinked so pi can read/write the same palace as opencode-devbox
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
## Versioning
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc. `latest` always points at the most recent release. The pi binary is inherited from `joakimp/pi-devbox:base-pi-only`, so each release follows an opencode-devbox release that bakes the target pi version. (`base-pi-only` is an internal building-block tag — pull `latest` or a `vX.Y.Z` tag instead.)
For container-level rebuilds on the same pi version (security updates, base bumps, fixes) the tag gets a letter suffix: `v0.74.0b`, `v0.74.0c`, …
## Persistent state
User edits and pi-installed packages survive container recreation when you mount these named volumes. Use the included `docker-compose.yml` and they're set up automatically.
| Volume | Mount point | What it holds |
|---|---|---|
| `devbox-pi-config` | `/home/developer/.pi/` | pi settings, extension toggles, sessions, user-installed pi packages (`npm install -g`, `pi install npm:…`) |
| `devbox-shell-history` | `/home/developer/.cache/bash` | bash history |
| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump database |
| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state |
| `devbox-uv` | `/home/developer/.local/share/uv` | uv Python installs and tool cache |
Optional volumes for MemPalace (commented out by default — uncomment in `docker-compose.yml` to persist conversation memory across restarts):
| Volume | Mount point | What it holds |
|---|---|---|
| `devbox-palace` | `/home/developer/.mempalace` | palace data (drawers, knowledge graph, embeddings) |
| `devbox-chroma-cache` | `/home/developer/.cache/chroma` | ChromaDB embedding model cache (~80 MB, can be rebuilt) |
## User-installed pi packages
`NPM_CONFIG_PREFIX` is set inside the container to `/home/developer/.pi/npm-global`. Anything you `pi install npm:<pkg>` or `npm install -g` lands on the `devbox-pi-config` named volume — survives container recreation **and** image rebuilds. A user-installed `pi` wins over the baked one via `PATH` order, so you can pin a different pi version without rebuilding the image.
## Source
- **This image**: https://gitea.jordbo.se/joakimp/pi-devbox
- **Base image**: https://gitea.jordbo.se/joakimp/opencode-devbox (Hub: `joakimp/opencode-devbox`)
- **pi**: https://github.com/earendil-works/pi
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
## License
MIT (the image; pi and the bundled tools each carry their own licenses).
-54
View File
@@ -1,54 +0,0 @@
# pi-devbox — pi coding-agent container
#
# Builds on top of the opencode-devbox base image, which provides:
# Debian trixie, Node.js, AWS CLI, mempalace + MCP server, gitea-mcp,
# dev tools (neovim, tmux, bat, eza, fzf, zoxide, ripgrep, uv, rustup),
# user setup (developer/gosu), entrypoints, chromadb prewarm.
#
# This image adds only pi itself and its companion repos.
#
# Build args:
# BASE_IMAGE — base image to build from (default: base-latest)
# PI_VERSION — pi npm version: "latest" or a pinned version e.g. "0.74.0"
# PI_TOOLKIT_REF — git ref for pi-toolkit (default: main)
# PI_EXTENSIONS_REF — git ref for pi-extensions (default: main)
ARG BASE_IMAGE=joakimp/opencode-devbox:base-latest
FROM ${BASE_IMAGE}
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Install pi and clone companion repos.
# NPM_CONFIG_PREFIX is overridden to /usr so the baked binary lands at the
# system prefix — same pattern as opencode-devbox's variant Dockerfile.
# At runtime, NPM_CONFIG_PREFIX is reset to /home/developer/.pi/npm-global
# (inherited from base ENV) so user-installed packages land on the named
# volume and survive container recreate.
#
# git clone is wrapped in a retry loop because gitea.jordbo.se occasionally
# returns transient HTTP 500s on the first request after idle.
RUN set -e && \
git_clone_retry() { \
url="$1"; ref="$2"; dest="$3"; \
for i in 1 2 3 4 5; do \
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
rm -rf "$dest"; \
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
if [ "${PI_VERSION}" = "latest" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
else \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)"
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+407
View File
@@ -0,0 +1,407 @@
# pi-devbox — base image (variant-independent layers)
#
# This Dockerfile produces an image tagged base-<hash>, used as the parent
# for all published variants of pi-devbox. It contains everything that does
# not depend on variant-specific build-args (the pi install moves to
# Dockerfile.variant).
#
# The base is rebuilt only when this file or anything it COPYs in changes
# (rootfs/, entrypoint*.sh). Version bumps to PI_VERSION etc. do NOT
# trigger a base rebuild.
#
# To force a base rebuild for fresh apt packages without other code
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-06-09 (v1.0.0 — decoupled from opencode-devbox)
#
# ── Lineage note ─────────────────────────────────────────────────────
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
# variant; this file is the start of an independent build chain. The
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
# not appear here. The base is otherwise broadly equivalent so generic
# upstream improvements (CVE updates, new dev tooling) can be cherry-
# picked between repos.
# ─────────────────────────────────────────────────────────────────────
ARG DEBIAN_VERSION=trixie-slim
FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH
LABEL maintainer="joakimp"
LABEL description="pi-devbox — base image (variant-independent)"
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
# Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
# ── Core system packages ─────────────────────────────────────────────
# apt-get upgrade picks up any security/CVE fixes published between
# debian:trixie-slim base-image rebuilds. Paired with the index update
# and the install in the same layer so we don't bloat image history.
#
# Additions vs the upstream opencode-devbox base (2026-06-09):
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
# preview/export pipelines and broadly useful for any
# agent-driven document workflow. ~200 MB.
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
# yq — YAML-aware companion to jq.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
git \
openssh-client \
gnupg \
jq \
yq \
ripgrep \
fd-find \
tree \
less \
htop \
tmux \
make \
patch \
diffutils \
git-crypt \
age \
file \
sudo \
locales \
procps \
unzip \
gcc \
g++ \
rsync \
python3-pip \
python3-venv \
pandoc \
graphviz \
imagemagick \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# ── tmux defaults: 0-indexed windows and panes ───────────────────────
# pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to
# `<session>:0.0`. Containers that ship tmux with default options are
# already 0-indexed; this file makes the assumption explicit so future
# /etc/tmux.conf consumers can read it. Users can override per-user
# in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail
# to find its REPL session.
RUN printf '%s\n' \
'# pi-devbox baked default — see Dockerfile.base.' \
'# pi-studio targets tmux session :0.0; do not change these here.' \
'set -g base-index 0' \
'set -g pane-base-index 0' \
> /etc/tmux.conf
# ── SSH client defaults: ControlMaster on a writable socket path ──────
# Why this exists: the devbox typically mounts ~/.ssh from the host as
# read-only (security: keys are readable, but agents can't tamper with
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
# kex_exchange_identification: Connection closed by remote host
# The latter manifests downstream of CGNAT per-destination flow caps
# (~4 concurrent flows on most European residential ISPs) which silently
# drop further SYNs once exceeded — making fresh ssh attempts fail with
# banner-exchange timeouts that look like a remote problem.
#
# Fix: set a system-wide default ControlPath in /tmp (per-container,
# tmpfs-friendly, always writable) so multiplexing Just Works without
# touching the read-only ~/.ssh mount. Per-host overrides in user's
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired.
#
# ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
# (mode 700) on each container start.
RUN mkdir -p /etc/ssh/ssh_config.d && \
printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
'# Override per-host in ~/.ssh/config if the master socket location' \
'# needs to differ.' \
'Host *' \
' ControlMaster auto' \
' ControlPath /tmp/sshcm/%r@%h:%p' \
' ControlPersist 10m' \
' ServerAliveInterval 30' \
' ServerAliveCountMax 6' \
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy: default is `latest` — resolved at build time by
# following the /releases/latest redirect and reading the tag from the
# Location header. Every base rebuild picks up the newest upstream
# release. Explicit pins still work via build-args (e.g.
# --build-arg GOSU_VERSION=1.19).
# gosu — privilege de-escalation
ARG GOSU_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GOSU_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing gosu ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
chmod +x /usr/local/bin/gosu && \
gosu --version
# fzf — fuzzy finder
ARG FZF_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${FZF_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing fzf ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
fzf --version
# git-lfs
ARG GIT_LFS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GIT_LFS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing git-lfs ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
rm -rf /tmp/git-lfs-${V} && \
git lfs install --system && \
git-lfs --version
# gitleaks
ARG GITLEAKS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
V="${GITLEAKS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing gitleaks ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
chmod +x /usr/local/bin/gitleaks && \
gitleaks version
# neovim
ARG NVIM_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
V="${NVIM_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing neovim ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
nvim --version | head -1
# bat
ARG BAT_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${BAT_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing bat ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
rm -rf /tmp/bat-v${V}-* && \
bat --version
# eza
ARG EZA_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${EZA_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing eza ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
eza --version | head -1
# zoxide
ARG ZOXIDE_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${ZOXIDE_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing zoxide ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
zoxide --version
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
ARG UV_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${UV_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing uv ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
rm -rf /tmp/uv-* && \
uv --version
# ── MemPalace — local-first AI memory system ─────────────────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
# time to shave ~300 MB.
ARG INSTALL_MEMPALACE=true
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache mempalace && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
chmod +x /usr/local/bin/rustup-init
# gitea-mcp — MCP server for Gitea API
ARG GITEA_MCP_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
V="${GITEA_MCP_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing gitea-mcp ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin/ gitea-mcp && \
chmod +x /usr/local/bin/gitea-mcp && \
gitea-mcp --version
# Locales
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# ── Node.js (required for pi + MCP servers + tldr) ──
ARG NODE_VERSION=22
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# ── tldr (tealdeer) — community-maintained command examples ──────────
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
ARG TEALDEER_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TEALDEER_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing tealdeer ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
chmod +x /usr/local/bin/tldr && \
tldr --version
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
arm64) echo "aarch64" ;; \
*) echo "x86_64" ;; \
esac) && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
unzip -q /tmp/awscli.zip -d /tmp && \
/tmp/aws/install && \
rm -rf /tmp/aws /tmp/awscli.zip && \
aws --version
# ── Non-root user ────────────────────────────────────────────────────
ARG USER_NAME=developer
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
# Standard directories
RUN mkdir -p /workspace \
/home/${USER_NAME}/.pi/agent/extensions \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.cache/bash \
/home/${USER_NAME}/.ssh && \
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
# ── Pre-warm chromadb embedding model ──────────────────────────────
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
ef = ONNXMiniLM_L6_V2(); \
_ = ef(['warmup']); \
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
fi
# ── User-writable npm global prefix on the devbox-pi-config volume ──
# Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant).
# Runtime npm/pi installs use this prefix → land on the named volume.
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
# Start as root — entrypoint adjusts UID/GID then drops to developer
WORKDIR /workspace
ENTRYPOINT ["entrypoint.sh"]
CMD ["bash", "-l"]
+109
View File
@@ -0,0 +1,109 @@
# pi-devbox — variant image
#
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
# the variant-specific tools — currently just the pi install. Kept as a
# separate file (rather than collapsed into Dockerfile.base) so future
# variants (e.g. studio, studio-tex) can FROM the variant or extend
# this Dockerfile with additional build args without rebuilding the
# base on every pi version bump.
#
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
# CI computes the base hash from Dockerfile.base + rootfs/ +
# entrypoint*.sh and feeds it in.
#
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
# `npm install -g` by the developer user lands on the named volume.
# At BUILD time we want the baked binaries on /usr so they survive the
# volume mount. Each `npm install -g` below therefore prefixes the
# command with `NPM_CONFIG_PREFIX=/usr`.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG USER_NAME=developer
# ── pi coding-agent + companions ─────────────────────────────────────
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
# runs each repo's install.sh on container start so symlinks land under
# ~/.pi/agent/ on the named volume.
#
# PI_VERSION should be passed explicitly by CI as a concrete version
# (resolved from `npm view @earendil-works/pi-coding-agent version`).
# The default `latest` is for local dev convenience only — it has a
# known cache-hit footgun in registry-cached CI builds: the resulting
# build-arg string is byte-identical across builds, the layer-hash is
# identical, and the registry buildcache silently reuses the layer
# from whatever pi version was current when the cache was first
# populated. CI MUST pass a resolved concrete version. See pi-devbox
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
# under elpapi42. CI resolves these to commit SHAs to defeat the same
# cache-hit footgun that affects PI_VERSION.
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
ARG PI_FORK_REF=master
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
ARG PI_OBSMEM_REF=master
RUN set -e && \
git_clone_retry() { \
url="$1"; ref="$2"; dest="$3"; \
for i in 1 2 3 4 5; do \
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
rm -rf "$dest"; \
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
git_fetch_ref() { \
url="$1"; ref="$2"; dest="$3"; \
rm -rf "$dest"; mkdir -p "$dest"; \
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
for i in 1 2 3 4 5; do \
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
if [ "${PI_VERSION}" = "latest" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
else \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
# ── Optional: Go toolchain ───────────────────────────────────────────
# Off by default; opt in for users who run Go tools inside the devbox.
ARG INSTALL_GO=false
ARG GO_VERSION=latest
RUN if [ "${INSTALL_GO}" = "true" ]; then \
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${GO_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
fi && \
[ -n "$V" ] && \
echo "Installing Go ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
fi
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+373 -36
View File
@@ -1,58 +1,395 @@
# pi-devbox # pi-devbox
A Docker container image with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on the [opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox) base image. 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 ## What's inside
Built on `opencode-devbox:base-latest`, which provides: ### The pi coding-agent
- **Debian trixie** (stable base) - `pi` — the pi-coding-agent CLI (`@earendil-works/pi-coding-agent`)
- **Node.js** (LTS), **uv** (Python), **rustup** (Rust on-demand) - `pi-toolkit` — keybindings, AWS env loader, settings template
- **AWS CLI** v2 - `pi-extensions` — TypeScript extensions for pi (preview, MCP bridges,
- **MemPalace** + MCP server (persistent agent memory across sessions) mempalace integration, etc.)
- **Gitea MCP** server - `pi-fork` — the `fork` tool for spawning sub-agents
- **Dev tools**: neovim (LazyVim), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make - `pi-observational-memory` — the `recall` tool for session compaction
- **Shell**: bash with history tuning, prefix-search, fzf/zoxide integration
This image adds: ### MemPalace (AI memory)
- **pi** (`@earendil-works/pi-coding-agent`) — baked at `/usr/bin/pi` - `mempalace` — local-first agent memory system (29 MCP tools)
- **pi-toolkit**keybindings, env loader, settings template (cloned to `/opt/pi-toolkit`) - `mempalace-toolkit`bash wrappers for session/docs mining
- **pi-extensions** — ext-toggle, todo, ssh-controlmaster, notify, git-checkpoint, mcp-loader, confirm-destructive (cloned to `/opt/pi-extensions`) - ChromaDB embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
- **mempalace bridge** — `mempalace.ts` extension symlinked from `/opt/mempalace-toolkit`
The host-mounted palace at `~/.mempalace` is shared across the host and
this container so all your agents share one brain.
### Modern CLI tooling
| Tool | Purpose |
|---|---|
| `nvim` | Neovim text editor |
| `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 |
### 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.
## Quickstart ## 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 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 ```bash
cp .env.example .env
# edit .env — set WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL
docker compose run --rm devbox
# inside the container:
pi pi
``` ```
## Versioning 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).
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc. ### Stop / recreate / update
`latest` always points at the most recent release.
## Persistence ```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
```
| Volume | What it holds | ## Image variants
|--------|---------------|
| `devbox-pi-config` | pi settings, extensions toggle state, sessions (`~/.pi/`) |
| `devbox-shell-history` | bash history |
| `devbox-zoxide` | zoxide directory jump history |
| `devbox-nvim-data` | neovim plugins, Mason packages |
| `devbox-uv` | uv Python installs and tool cache |
## User-installed pi packages Currently published:
`NPM_CONFIG_PREFIX` is set to `/home/developer/.pi/npm-global`, so any `pi install npm:...` or `npm install -g` as the `developer` user lands on the `devbox-pi-config` volume and survives container recreation and image rebuilds. A user-installed pi wins over the baked binary via `PATH` order. | 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 |
## Source Planned for upcoming minor releases:
- [pi-devbox](https://gitea.jordbo.se/joakimp/pi-devbox) — this repo - `joakimp/pi-devbox:latest-studio` — adds [pi-studio](https://github.com/omaclaren/pi-studio)
- [opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox) — base image source for browser-based prompt editing, KaTeX/Mermaid preview, and
- [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) literate REPLs (Shell / Python / IPython / Julia / R / GHCi /
- [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) Clojure). Adds ~50 MB.
- `joakimp/pi-devbox:latest-studio-tex` — also adds `texlive-xetex`
for PDF export from Studio. Adds ~600 MB on top of `-studio`.
## 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
env_file: .env
environment:
- TZ=${TZ:-Europe/Stockholm}
- TERM=xterm-256color
- AWS_PROFILE=${AWS_PROFILE:-}
- AWS_REGION=${AWS_REGION:-eu-west-1}
volumes:
# Workspace: your host source tree, read-write
- ${HOST_WORKSPACE:-./workspace}:/workspace:rw
# SSH keys: read-only from host
- ${HOME}/.ssh:/home/developer/.ssh:ro
# AWS config: read-only from host
- ${HOME}/.aws:/home/developer/.aws:ro
# MemPalace: bind-mounted so host pi and container pi share a brain
- ${HOME}/.mempalace:/home/developer/.mempalace:rw
# Per-container persistent state
- devbox-pi-config:/home/developer/.pi
- devbox-bash-history:/home/developer/.cache/bash
- devbox-nvim-data:/home/developer/.local/share/nvim
- devbox-uv-tools:/opt/uv-tools
- devbox-chroma-cache:/home/developer/.cache/chroma
volumes:
devbox-pi-config:
devbox-bash-history:
devbox-nvim-data:
devbox-uv-tools:
devbox-chroma-cache:
```
See `.env.example` in the repo for available environment variables.
## 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 | host filesystem |
| `~/.ssh` | host bind-mount (read-only) | host filesystem |
| `~/.aws` | host bind-mount (read-only) | host filesystem |
| `~/.mempalace` | host bind-mount | host filesystem |
| `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes |
| `~/.cache/bash` | named volume | `down -v` wipes |
| `~/.local/share/nvim` | named volume | `down -v` wipes |
| `/opt/uv-tools` | named volume | `down -v` wipes |
| `~/.cache/chroma` | named volume | `down -v` wipes |
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.
## 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.
## 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`
(planned for `:latest-studio`) hard-codes its tmux send target to
`<session>:0.0`. If you override `base-index` to 1 in a personal
`~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
## 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. The `:latest` and `vX.Y.Z` tags are produced from
this layer; future Studio variants will extend further.
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 |
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).
## 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.
### Smoke-testing a local build
```bash
./scripts/smoke-test.sh joakimp/pi-devbox:latest
```
## 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
+21 -1
View File
@@ -16,8 +16,14 @@ services:
# To build from source instead of pulling from Docker Hub: # To build from source instead of pulling from Docker Hub:
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.variant
# args: # args:
# PI_VERSION: "latest" # # Pin a specific base build by hash instead of tracking base-latest:
# BASE_IMAGE: "joakimp/pi-devbox:base-<hash>"
# # PI_VERSION must be a concrete version, not 'latest', to defeat
# # the registry-buildcache cache-hit footgun. CI resolves this from
# # the npm registry; for a local build you can set it manually.
# PI_VERSION: "0.79.1"
container_name: pi-devbox container_name: pi-devbox
stdin_open: true stdin_open: true
tty: true tty: true
@@ -35,12 +41,25 @@ services:
# SSH keys (read-only) — for git push/pull # SSH keys (read-only) — for git push/pull
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro - ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Optional: host-owned shell config + LAN jump overrides. The image's
# ~/.bash_aliases sources ~/.config/devbox-shell/bash_aliases if present,
# and setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
# named-peer `ProxyJump host` overrides (reach LAN peers by name via
# `dssh <peer>`; see opencode-devbox's ssh-lan.conf.example).
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
# Optional: mount skillset repo for automatic skill/instruction deployment. # Optional: mount skillset repo for automatic skill/instruction deployment.
# - ${SKILLSET_PATH}:/home/developer/skillset # - ${SKILLSET_PATH}:/home/developer/skillset
# Persist pi config (settings.json, extensions, sessions, auth) # Persist pi config (settings.json, extensions, sessions, auth)
- devbox-pi-config:/home/developer/.pi - devbox-pi-config:/home/developer/.pi
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
# setup-lan-access.sh generates this key once and reuses it; persisting
# it means you authorize it on the host ONCE rather than re-authorizing
# after every `docker compose up --force-recreate`.
- devbox-ssh-local:/home/developer/.ssh-local
# Persist bash history across container recreations # Persist bash history across container recreations
- devbox-shell-history:/home/developer/.cache/bash - devbox-shell-history:/home/developer/.cache/bash
@@ -64,6 +83,7 @@ services:
volumes: volumes:
devbox-pi-config: devbox-pi-config:
devbox-ssh-local:
devbox-shell-history: devbox-shell-history:
devbox-zoxide: devbox-zoxide:
devbox-nvim-data: devbox-nvim-data:
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
# ── SSH ControlMaster socket dir ────────────────────────────────
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
# creates the directory with the right permissions on every container
# start. /tmp is per-container so the dir doesn't survive recreation;
# baking it into a Dockerfile layer would be wrong.
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
# others can write to.
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default; this generates a
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
fi
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
# Respects host bind-mounts and user customizations — existing files
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
# directly.
SKEL_DIR="/etc/skel-devbox"
if [ -d "$SKEL_DIR" ]; then
for f in .bash_aliases .inputrc; do
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
cp "$SKEL_DIR/$f" "$HOME/$f"
fi
done
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve
# existing data. `--yes` auto-accepts detected entities so the init is
# non-interactive.
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
PALACE_DIR="${HOME}/.mempalace"
if [ ! -d "$PALACE_DIR/palace" ]; then
echo "Initializing MemPalace for workspace (non-interactive)..."
# </dev/null: mempalace init has an interactive "Mine this directory
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
# Without redirected stdin, the process blocks here forever when run
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
# makes the prompt fall through to its default (skip).
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
fi
fi
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
# Each install.sh is idempotent and backs up real files before linking,
# so re-running across container restarts is safe.
#
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
# and writes the AWS env loader), then pi-extensions (symlinks our
# extensions), then settings.json bootstrap from the toolkit template,
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
# install_skill is intentionally skipped to avoid racing with skillset
# auto-deploy below).
if command -v pi &>/dev/null; then
if [ -d /opt/pi-toolkit ]; then
(cd /opt/pi-toolkit && ./install.sh --yes) || \
echo "WARN: pi-toolkit install.sh failed (continuing)"
fi
if [ -d /opt/pi-extensions ]; then
(cd /opt/pi-extensions && ./install.sh --yes) || \
echo "WARN: pi-extensions install.sh failed (continuing)"
fi
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
[ -f /opt/pi-toolkit/settings.example.json ]; then
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
fi
# pi↔mempalace MCP bridge — single extension symlink.
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
command -v mempalace &>/dev/null && \
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
"$HOME/.pi/agent/extensions/mempalace.ts"
fi
# pi-fork (fork tool) + pi-observational-memory (recall tool).
# These are pi packages (not symlink-style extensions): they're cloned to
# /opt with node_modules baked at BUILD time, then registered here via
# `pi install <local-path>`. A local-path install is instant + in-place
# (pi loads the extension directly from /opt) + idempotent (no duplicate
# package entry on re-run), and stores a relative path that resolves into
# the image-layer /opt so it survives volume recreate. The fork/recall
# tools register on the NEXT pi start (extensions bind at startup). Guard
# on settings.json so we only install once per volume.
for _pkg in /opt/pi-fork /opt/pi-observational-memory; do
[ -d "$_pkg" ] || continue
_name=$(basename "$_pkg")
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
pi install "$_pkg" >/dev/null 2>&1 || \
echo "WARN: pi install $_name failed (continuing)"
fi
done
fi
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions.
# This ensures skills resolve correctly inside the container regardless of
# where the repo lives on the host. Idempotent — second run is a no-op.
#
# Detection order:
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
# 3. /workspace/skillset (skillset is directly inside workspace root)
SKILLSET_DEPLOY=""
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
fi
if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
fi
# ── Execute command ──────────────────────────────────────────────────
exec "$@"
Executable
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
USER_NAME="developer"
CURRENT_UID=$(id -u "$USER_NAME")
CURRENT_GID=$(id -g "$USER_NAME")
# ── UID/GID adjustment ───────────────────────────────────────────────
# Priority per dimension: env var > auto-detect from /workspace > no-op
# UID and GID are detected independently so a GID-only mismatch (e.g. host
# user has UID 1000 but primary group at GID 1001) is still corrected.
TARGET_UID="${USER_UID:-}"
TARGET_GID="${USER_GID:-}"
if [ -d /workspace ]; then
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
# Adopt workspace UID if env var not set and workspace is non-root-owned
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
TARGET_UID="$WORKSPACE_UID"
fi
# Adopt workspace GID if env var not set and workspace group differs
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
TARGET_GID="$WORKSPACE_GID"
fi
fi
# Apply UID/GID changes if needed
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
echo "Adjusted developer GID to $TARGET_GID"
fi
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
echo "Adjusted developer UID to $TARGET_UID"
fi
# ── SSH key permissions ──────────────────────────────────────────────
# If SSH keys are mounted, fix permissions (skip if read-only mount)
if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then
rm -f "/home/$USER_NAME/.ssh/.perm_test"
chmod 700 "/home/$USER_NAME/.ssh"
find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
[ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
[ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
fi
fi
# ── Fix ownership of named volume mount points ──────────────────────
# Named volumes are created as root on first use. Fix ownership so the
# developer user can write to them.
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
# First, fix parent dirs that Docker auto-creates as root:root when it
# materializes nested mount points (e.g. mounting a volume at
# .local/state/opencode creates .local/state as root). Non-recursive —
# we only need the dir node itself; children are handled below or were
# created by the user.
for parent in \
/home/"$USER_NAME"/.local \
/home/"$USER_NAME"/.local/share \
/home/"$USER_NAME"/.local/state \
/home/"$USER_NAME"/.cache \
/home/"$USER_NAME"/.config; do
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
fi
done
for dir in \
/home/"$USER_NAME"/.local/share/opencode \
/home/"$USER_NAME"/.local/state/opencode \
/home/"$USER_NAME"/.local/share/uv \
/home/"$USER_NAME"/.local/share/zoxide \
/home/"$USER_NAME"/.local/share/nvim \
/home/"$USER_NAME"/.mempalace \
/home/"$USER_NAME"/.cache/bash \
/home/"$USER_NAME"/.cache/chroma \
/home/"$USER_NAME"/.rustup \
/home/"$USER_NAME"/.cargo \
/home/"$USER_NAME"/.vscode-server \
/home/"$USER_NAME"/.config/opencode \
/home/"$USER_NAME"/.config/nvim \
/home/"$USER_NAME"/.pi \
/home/"$USER_NAME"/.ssh-local \
/home/"$USER_NAME"/.agents/skills; do
[ -d "$dir" ] || continue
# Sentinel-file fast path: on volumes with thousands of files (nvim
# plugins, palace data) the recursive chown used to cost multiple
# seconds on every container start even when ownership was already
# correct. Now we write a sentinel after a successful chown and skip
# the walk when the sentinel matches the target UID:GID.
#
# If USER_UID changes between runs (user switches hosts, different
# workspace owner), the sentinel won't match and the full chown runs.
sentinel="$dir/.devbox-owner"
expected="$FINAL_UID:$FINAL_GID"
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
continue
fi
# Recursive chown needed. Only do it when the top-level differs too
# (covers the common case of fresh root-owned named volumes).
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
fi
# Write sentinel so subsequent starts skip the recursive walk.
# Suppress errors — a read-only mount would fail here, but that would
# already have failed above on the chown itself.
echo "$expected" > "$sentinel" 2>/dev/null || true
done
# ── Drop to developer user for remaining setup ──────────────────────
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
+105
View File
@@ -0,0 +1,105 @@
# opencode-devbox bash aliases and customizations
# Sourced by the Debian-default ~/.bashrc on shell startup.
# To override, bind-mount your host's ~/.bash_aliases over this file
# via docker-compose.yml.
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
# recommended pattern for sharing aliases/PATH/utilities between host
# and container), source the bash_aliases file from it. This survives
# --force-recreate because it's baked into the image's skel, not the
# container's writable layer. Hosts that don't use this pattern are
# unaffected — the test silently skips if the file doesn't exist.
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
# ── History persistence and quality ──────────────────────────────────
# The named volume devbox-shell-history is mounted at ~/.cache/bash
# so history survives container recreation.
export HISTFILE="${HOME}/.cache/bash/history"
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
# Large, time-stamped, deduplicated history. Append rather than overwrite.
export HISTSIZE=100000
export HISTFILESIZE=200000
export HISTCONTROL=ignoreboth:erasedups
export HISTTIMEFORMAT='%F %T '
shopt -s histappend 2>/dev/null
shopt -s cmdhist 2>/dev/null
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
# after zoxide's init runs. Installing it here would create a
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
# as its separator and prepends itself; two adjacent ';' breaks the
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
# ── Common aliases ───────────────────────────────────────────────────
# Prefer eza (modern ls) when available
if command -v eza >/dev/null 2>&1; then
alias ls='eza --group-directories-first'
alias ll='eza -lh --group-directories-first --git'
alias la='eza -lha --group-directories-first --git'
alias tree='eza --tree'
else
alias ll='ls -lh'
alias la='ls -lha'
fi
# Prefer bat (syntax-highlighted cat) when available
if command -v bat >/dev/null 2>&1; then
alias cat='bat --style=plain --paging=never'
alias less='bat --paging=always'
fi
# Git shortcuts
alias gs='git status'
alias gd='git diff'
alias gl='git log --oneline --graph --decorate -20'
# ── LAN access via the host (dssh) ───────────────────────────────────
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
# / `scp -F` against that config. Guarded so they only appear when the config
# was actually generated (no-op / absent on native Linux hosts).
if [ -r "$HOME/.ssh-local/config" ]; then
alias dssh='ssh -F "$HOME/.ssh-local/config"'
alias dscp='scp -F "$HOME/.ssh-local/config"'
fi
# Safety: confirm before destructive ops
alias rm='rm -i'
alias mv='mv -i'
alias cp='cp -i'
# ── Shell integrations ───────────────────────────────────────────────
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
if command -v zoxide >/dev/null 2>&1; then
eval "$(zoxide init bash)"
fi
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
# We install fzf from GitHub releases (not apt), so sourcing from the
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
# binary's own --bash flag (available since fzf 0.48) for setup.
if command -v fzf >/dev/null 2>&1; then
eval "$(fzf --bash)" 2>/dev/null || true
fi
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
# Installed AFTER zoxide init so zoxide's hook is already in place;
# we append with a newline separator to avoid the ';;' parse error
# described at the top of this file. Guarded so repeated sourcing
# (e.g. `exec bash`) doesn't stack duplicates.
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1
fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
# Preserves the default Debian PS1 logic but prefixes with a container marker.
# We check for the literal '[devbox]' substring in PS1 rather than relying on
# an exported guard variable — otherwise `exec bash` inherits the guard but
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
# re-added in the new shell.
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
fi
+27
View File
@@ -0,0 +1,27 @@
# opencode-devbox readline defaults
# To override, bind-mount your host's ~/.inputrc over this file
# via docker-compose.yml.
# Inherit system-wide defaults (colour, 8-bit input, …) if present
$include /etc/inputrc
# ── History search on Up/Down ────────────────────────────────────────
# Type a prefix, press Up, and walk through previous commands starting
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
"\e[A": history-search-backward
"\e[B": history-search-forward
"\e[1;5A": previous-history
"\e[1;5B": next-history
# ── Completion quality ───────────────────────────────────────────────
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
set completion-ignore-case on # case-insensitive file/dir completion
set colored-stats on # colour ls-style completion list entries
set colored-completion-prefix on # highlight the matched prefix
set visible-stats on # append /*@ type indicators in completion
set mark-symlinked-directories on # add trailing / to symlinks to dirs
set skip-completed-text on # don't re-insert already-typed text
# Treat hyphens and underscores as equivalent when completing (e.g.
# typing `foo-` matches both `foo-bar` and `foo_bar`).
set completion-map-case on
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
#
# THE PROBLEM
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
# container runs inside a Linux VM behind the host's network stack. The
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
# are NOT bridged into the container by default — only the host itself and
# *routed* subnets are reachable. On native Linux Docker the default bridge
# already NATs container egress onto the host's LAN, so LAN peers are usually
# reachable directly and no workaround is needed.
#
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
# The one thing reachable from a container on every OS is the host itself
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
# config that reaches the host and lets the user ProxyJump onward to LAN
# peers the host can reach. On native Linux we do nothing.
#
# We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
# is pulled in via the `Include ~/.ssh/config` line below.
#
# WHY A WRITABLE SIDECAR (~/.ssh-local)
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
# can read keys for git but can't tamper with config/known_hosts/authorized_
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
# So everything generated here lives under the writable ~/.ssh-local, used
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
#
# CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
# jump to authenticate. If unset we still generate the config but print
# a hint with the public key to authorize on the host.
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
# LAN the (roaming) host is currently joined to, without naming peers.
# Matches by the address you TYPE, not the resolved HostName, so it never
# overrides named hosts that already carry their own ProxyJump.
#
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
# Named LAN peers are facts about a *specific* host's network, not about the
# image — a roaming laptop sees different LANs. So we never bake peer names
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
# (the same devbox-shell bridge dir used for shared aliases), we Include it
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
# Host pve pve-2 pbs-vm
# ProxyJump host
# First-value-wins means ProxyJump is taken from there while HostName/User/
# IdentityFile are inherited from the matching block in ~/.ssh/config.
#
# SCOPING NOTE (important)
# `Include` is scoped to the enclosing Host/Match block. So every Include
# below is preceded by a bare `Host *` to reset the active context to
# match-all — otherwise the included config would only apply when targeting
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
# defaults.
#
# Idempotent: re-renders the config every run (cheap); never regenerates the
# key. Always non-fatal — never blocks container startup.
set -uo pipefail
MODE="${DEVBOX_LAN_ACCESS:-auto}"
[ "$MODE" = "off" ] && exit 0
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
SSH_LOCAL="${HOME}/.ssh-local"
CONFIG="${SSH_LOCAL}/config"
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
# in which case the jump is still harmless / usable, and they can force it
# with DEVBOX_LAN_ACCESS=jump).
is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
}
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
# Native Linux host: LAN peers are reachable directly. Nothing to do.
exit 0
fi
# From here: MODE=jump, or MODE=auto on a VM-backed host.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
# ── Jump key (generated once; preserved across restarts) ──────────────
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
# is generated only on the very first start (or if the volume is wiped). When
# we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below.
KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
fi
# ── Render the writable config ────────────────────────────────────────
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
# Scope reset to match-all so the Include applies to every target host.
Host *
Include ~/.config/devbox-shell/ssh-lan.conf
EOF
)
fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK=""
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
# and go direct via the container's NAT egress. NOTE: also matches the
# container's own bridge subnet and any private IP the host can't actually
# reach — for non-LAN private hosts behind a different jump, use their named
# entry (which matches first by name and keeps its own ProxyJump).
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
ProxyJump host
EOF
)
fi
INCLUDE_BLOCK=""
if [ -r "${HOME}/.ssh/config" ]; then
INCLUDE_BLOCK=$(cat <<'EOF'
# Your own target hosts. Scope reset to match-all so this Include applies to
# every target (an Include is otherwise scoped to the enclosing Host block).
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
Host *
Include ~/.ssh/config
EOF
)
fi
cat > "$CONFIG" <<EOF
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
# (or the dssh / dscp aliases). See the script header for the full rationale.
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
# Also redirect ControlPath into the writable sidecar: the bind-mounted
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
# but ~/.ssh is read-only here so the master socket can't be created and those
# hosts fail to connect. First-value-wins: setting it here (before the Include)
# overrides the read-only path for every host. Harmless when ControlMaster is off.
Host *
UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
EOF
chmod 600 "$CONFIG" 2>/dev/null || true
# ── Authorize hints ───────────────────────────────────────────────────
# Print the copy-paste authorize line whenever we either (a) can't yet
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
# (b) fires only on first-ever start (or after the volume is reset) — so this
# is normally a one-time, one-line step per machine, with no file to locate.
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
HOST_SSH_USER is unset so it can't authenticate to the host yet.
To enable container -> host -> LAN-peer access:
1. Set HOST_SSH_USER=<your host username> in the container env.
2. Authorize this key on the host (run ON THE HOST, once):
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
3. Ensure the host's SSH server (Remote Login) is enabled.
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
EOF
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
cat <<EOF
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
(Ensure the host's SSH server / Remote Login is enabled.)
This key is persisted in the ~/.ssh-local volume, so you won't need to
repeat this on container updates — only if that volume is reset.
EOF
fi
exit 0
+102 -9
View File
@@ -1,23 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# smoke-test.sh — basic sanity checks for the pi-devbox image # smoke-test.sh — sanity checks for the pi-devbox image
# #
# Usage: ./scripts/smoke-test.sh <image> # Usage: ./scripts/smoke-test.sh <image>
# #
# Verifies: # Verifies:
# - pi binary present and returns a version # - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
# - pi-toolkit cloned at /opt/pi-toolkit # - pi-toolkit cloned at /opt/pi-toolkit
# - pi-extensions cloned at /opt/pi-extensions # - pi-extensions cloned at /opt/pi-extensions
# - pi-fork + pi-observational-memory cloned with node_modules baked
# - entrypoint deploys pi-toolkit keybindings symlink # - entrypoint deploys pi-toolkit keybindings symlink
# - entrypoint deploys ≥4 extensions # - entrypoint deploys ≥4 extensions
# - mempalace bridge symlink present # - mempalace bridge symlink present
# - settings.json bootstrapped # - settings.json bootstrapped
# - pi-fork + pi-observational-memory registered via `pi install`
# - image size within threshold # - image size within threshold
set -euo pipefail set -euo pipefail
IMAGE="${1:?usage: $0 <image>}" IMAGE="${1:?usage: $0 <image>}"
PASS=0; FAIL=0 PASS=0; FAIL=0
SIZE_THRESHOLD_MB=2200 # pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
# imagemagick, yq, tealdeer, and a baked /etc/tmux.conf. Local arm64 build
# observed 3.20 GB. CI amd64 builds may differ slightly; threshold below
# carries +300 MB margin to absorb arch differences without false reds.
# Tighten in a follow-up release once amd64 actuals are observed in CI logs.
SIZE_THRESHOLD_MB=3500
run() { run() {
local label="$1"; local cmd="$2" local label="$1"; local cmd="$2"
@@ -28,24 +37,63 @@ run() {
fi fi
} }
# Stricter version of `run` that asserts an expected substring in stdout.
# Catches the "image bytes silently identical to previous release" class of
# regression — Docker layer cache hit on `npm install -g <pkg>` because the
# bare command string is identical across builds, even when `latest` would
# resolve differently. Discovered 2026-05-23 — every pi-devbox release
# v0.74.0..v0.75.5 had been shipping the same image bytes.
run_expect() {
local label="$1"; local cmd="$2"; local expect="$3"
local out
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
if echo "$out" | grep -Fq "$expect"; then
printf " ✅ %s (got %s)\n" "$label" "$expect"; PASS=$((PASS+1))
else
printf " ❌ %s — expected substring %q, got: %s\n" "$label" "$expect" "$out"; FAIL=$((FAIL+1))
fi
}
echo "=== pi-devbox smoke test: $IMAGE ===" echo "=== pi-devbox smoke test: $IMAGE ==="
echo "" echo ""
# ── Basic binary checks ─────────────────────────────────────────────── # ── Binaries ─────────────────────────────────────────────────────────
echo "── Binaries ──" echo "── Binaries ──"
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
run_expect "pi version matches build arg" "pi --version" "$EXPECTED_PI_VERSION"
else
run "pi" "pi --version" run "pi" "pi --version"
fi
run "node" "node --version" run "node" "node --version"
run "git" "git --version" run "git" "git --version"
run "aws" "aws --version" run "aws" "aws --version"
run "uv" "uv --version" run "uv" "uv --version"
run "nvim" "nvim --version" run "nvim" "nvim --version"
run "mempalace-mcp" "mempalace-mcp --help" run "mempalace-mcp" "mempalace-mcp --help"
# v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version"
run "graphviz (dot)" "dot -V"
run "imagemagick" "magick --version"
run "yq" "yq --version"
run "tldr (tealdeer)" "tldr --version"
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
echo ""
echo "── tmux config ──"
run_expect "/etc/tmux.conf has base-index 0" \
"cat /etc/tmux.conf" "set -g base-index 0"
run_expect "/etc/tmux.conf has pane-base-index 0" \
"cat /etc/tmux.conf" "set -g pane-base-index 0"
# ── Repo clones ─────────────────────────────────────────────────────── # ── Repo clones ───────────────────────────────────────────────────────
echo "" echo ""
echo "── Repo clones ──" echo "── Repo clones ──"
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD" run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD" run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
run "pi-fork clone + node_modules" \
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
# ── Runtime deployment (needs entrypoint to run) ────────────────────── # ── Runtime deployment (needs entrypoint to run) ──────────────────────
echo "" echo ""
@@ -58,9 +106,19 @@ CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; } cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
trap cleanup EXIT trap cleanup EXIT
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions # Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
for i in $(seq 1 30); do # Gate on BOTH the keybindings symlink (deployed by pi-toolkit) AND the
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then # mempalace.ts bridge (deployed last by entrypoint-user.sh) AND ≥4 *.ts
# extensions present. Parallel build load can otherwise sample the *.ts
# count mid-deploy and produce a flake. See opencode-devbox c6f9d11
# (2026-06-08) — same fix transplanted.
for i in $(seq 1 45); do
if docker exec "$CID" sh -c '
test -L /home/developer/.pi/agent/keybindings.json && \
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
[ "$count" -ge 4 ]
' >/dev/null 2>&1; then
break break
fi fi
sleep 1 sleep 1
@@ -80,11 +138,46 @@ exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/age
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok' exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok' exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
for i in $(seq 1 15); do
if docker exec "$CID" grep -q pi-observational-memory \
/home/developer/.pi/agent/settings.json 2>/dev/null; then
break
fi
sleep 1
done
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
# ── Image size ──────────────────────────────────────────────────────── # ── Image size ────────────────────────────────────────────────────────
echo "" echo ""
echo "── Image size ──" echo "── Image size ──"
SIZE_MB=$(docker image inspect "$IMAGE" --format='{{.Size}}' | awk '{printf "%d", $1/1048576}') # Sum all layers via `docker history`. Docker's `image inspect --format='{{.Size}}'`
if [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then # returns ONLY the variant-unique layer when the base is content-addressed and
# shared (the case in this repo's two-phase build), which understates the
# user-facing image size by 2+ GB. Summing layer sizes from history is the
# metric Hub displays to users and the one we actually want to gate on.
SIZE_MB=$(docker history --format '{{.Size}}' "$IMAGE" | python3 -c '
import sys, re
total=0.0
for line in sys.stdin:
s=line.strip()
if s in ("0B", ""): continue
m=re.match(r"^([0-9.]+)(B|kB|MB|GB)$", s)
if not m: continue
v=float(m.group(1)); u=m.group(2)
mult={"B":1/1048576,"kB":1/1024,"MB":1,"GB":1024}[u]
total+=v*mult
print(int(total))
')
if [ -z "$SIZE_MB" ] || [ "$SIZE_MB" = "0" ]; then
printf " ⚠️ image size: could not parse — skipping check\n"
elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1)) printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
else else
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1)) printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))