Compare commits

..

4 Commits

Author SHA1 Message Date
Joakim Persson c0d2516456 docs: fix quick-start — bare 'run devbox' lands in a shell, not opencode
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 10s
Validate / validate-base (push) Successful in 3m2s
Validate / validate-omos (push) Successful in 6m38s
The image's default CMD is bash -l, so 'docker compose run --rm devbox'
with no command drops into a login shell; you pass 'opencode' explicitly to
start the harness. The README quick-start claimed the opposite and carried a
garbled 'Use bash instead of (no command)' half-sentence; the same error was
mirrored in the Hub HUB_TEMPLATE. Fix both and regenerate DOCKER_HUB.md.
Doc-only — no image bytes change.
2026-06-13 23:24:49 +02:00
Joakim Persson ba8000732d v2.1.0: symlink OMOS bundled skills from image, bump opencode 1.17.4->1.17.5
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 10s
Validate / validate-omos (push) Successful in 4m15s
Validate / validate-base (push) Successful in 5m2s
Publish Docker Image / build-base (push) Successful in 30m33s
Publish Docker Image / smoke-base (push) Successful in 3m20s
Publish Docker Image / smoke-omos (push) Successful in 4m24s
Publish Docker Image / build-variant-base (push) Successful in 13m37s
Publish Docker Image / build-variant-omos (push) Successful in 30m18s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Deploy the five oh-my-opencode-slim bundled skills (clonedeps, codemap,
deepwork, oh-my-opencode-slim, simplify) by symlinking them from the image
path into ~/.agents/skills/ on every container start, instead of the
installer copying them into the persistent config volume on first run only.
Image-sourced links mean 'docker compose pull' + recreate refreshes the
skills with no installer run and no config reset; the old copy-on-first-run
froze them in the volume forever.

- entrypoint-user.sh: new non-fatal OMOS bundled-skills reconcile block
  (runs after skillset deploy so OMOS wins the simplify collision; absolute
  symlinks; gated by OMOS_SKILLS, now independent of ENABLE_OMOS). Both
  installer calls now pass --skills=no. One-time migration backs up (never
  deletes) frozen real copies in ~/.config/opencode/skills/ to .bak.<epoch>.
- scripts/smoke-test.sh: assert the bundled-skills source path on omos.
- Bump OPENCODE_VERSION 1.17.4 -> 1.17.5.
- Versioning: document the move to independent image semver (v2.0.0 was the
  decouple point), mirroring pi-devbox. README/AGENTS/.env.example/CHANGELOG
  updated; new docs/omos-skills.md.
2026-06-13 22:32:09 +02:00
pi 72298ae77e v2.0.0: remove pi, relocate npm-global prefix, bump opencode 1.17.2->1.17.4
Validate / base-change-warning (push) Successful in 14s
Validate / docs-check (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-omos (push) Successful in 12m42s
Validate / validate-base (push) Successful in 13m39s
Publish Docker Image / build-base (push) Successful in 44m17s
Publish Docker Image / smoke-base (push) Successful in 3m46s
Publish Docker Image / smoke-omos (push) Successful in 5m54s
Publish Docker Image / build-variant-base (push) Successful in 18m11s
Publish Docker Image / build-variant-omos (push) Successful in 19m34s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 15s
PR-5 (per docs/CLEANUP-v2.0.0.md). Major release with two breaking changes:

1. pi fully removed (deprecated in v1.17.2). Gone: INSTALL_PI + all PI_*
   build args; with-pi/omos-with-pi/pi-only variants; base-pi-only publish
   job; all ~/.pi entrypoint wiring; the 3 pi smoke/validate/build-variant
   CI jobs. Only base + omos variants remain (4 tags/release).

2. NPM_CONFIG_PREFIX relocated ~/.pi/npm-global -> ~/.config/opencode/npm-global
   (persistent in both compose files). entrypoint-user.sh gains a one-time
   migration shim that copies old global npm packages forward.

Also: opencode 1.17.2->1.17.4; DOCKER_HUB.md gains {{OPENCODE_VERSION}}
placeholder filled by CI at publish time (mirrors pi-devbox); full docs
drift sweep across README/AGENTS/.gitea-README/.env.example/manual-host-publish;
DOCKER_HUB.md regenerated + --check passes; both workflows YAML-valid;
all shell scripts pass bash -n.
2026-06-13 17:10:45 +02:00
pi c8217814c8 docs(v2.0.0): add explicit base-pi-only* Hub-tag purge step
Validate / docs-check (push) Failing after 7s
Validate / base-change-warning (push) Successful in 9s
Validate / validate-omos (push) Successful in 4m21s
Validate / validate-with-pi (push) Successful in 7m27s
Validate / validate-omos-with-pi (push) Successful in 5m52s
Validate / validate-base (push) Successful in 11m15s
Validate / validate-pi-only (push) Successful in 6m32s
Document that base-pi-only / base-pi-only-vX.Y.Z on joakimp/pi-devbox are
orphaned legacy artifacts (no pi-devbox build input references them) that
the build-variant-pi-only job re-publishes every release. Purge them from
the pi-devbox Hub repo after PR-5 removes the publisher; before that, the
floating tag just reappears. Added to removal steps + verification.
2026-06-10 22:34:03 +02:00
19 changed files with 639 additions and 1237 deletions
+6 -31
View File
@@ -98,34 +98,9 @@ SSH_KEY_PATH=~/.ssh
# Requires image built with INSTALL_OMOS=true
# ENABLE_OMOS=false
# OMOS_TMUX=false # Enable tmux multiplexer integration
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start
# ── pi coding-agent (alternative/complementary harness) ─────────────────
# Requires image built with INSTALL_PI=true.
# When the image is built with both INSTALL_OPENCODE=true (default) and
# INSTALL_PI=true, both harnesses share the same mempalace install and
# palace path — wing data is mutually visible to either harness.
#
# Pi version is baked at build time via PI_VERSION (default: latest at
# build). The baked `pi` binary is at /usr/bin/pi (system npm prefix);
# rebuild the image to upgrade it. NPM_CONFIG_PREFIX is set to
# /home/developer/.pi/npm-global, so anything installed via
# `pi install npm:...` or `npm install -g` as the developer user
# (themes, skills, extensions, including a user-installed pi itself)
# lands on the named volume and survives container recreate AND image
# rebuilds. A user-installed pi wins via PATH order over the baked one.
#
# Pi config (settings.json, extensions toggle state, sessions, auth) persists in the
# devbox-pi-config named volume mounted at ~/.pi/.
#
# To launch pi from a `compose run` invocation:
# docker compose run --rm devbox pi
# To attach to a running container:
# docker compose exec -u developer devbox pi
# Default `compose run` (no args) drops to bash; pick the harness yourself.
#
# Build args (set in docker-compose.yml or via --build-arg on docker build):
# INSTALL_PI=true # default false; opt-in
# PI_VERSION=latest # pin a specific version, e.g. 0.73.0
# INSTALL_OPENCODE=false # build a pi-only image (still has Bun in -omos)
# OMOS_SKILLS=true # Symlink bundled OMOS skills (clonedeps, codemap,
# # deepwork, oh-my-opencode-slim, simplify) from the
# # image into ~/.agents/skills/ each start; updates
# # on image pull. Independent of ENABLE_OMOS.
# # See docs/omos-skills.md
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start (does not affect skills)
+47 -47
View File
@@ -8,14 +8,16 @@ the build pipeline is shaped the way it is, you're in the right place.
| File | Trigger | Role |
|---|---|---|
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then five parallel variant deltas. ~4080 min wall clock depending on runner count and whether base needs rebuilding. |
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all five variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then two parallel variant deltas. ~4080 min wall clock depending on runner count and whether base needs rebuilding. |
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of both variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
## Why the split-base pipeline exists
opencode-devbox builds **five image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`, `pi-only`) × **two architectures** (amd64, arm64). Four opencode-bearing variants publish under this repo (**eight tags per release** + the floating `base-latest`); the `pi-only` build is pushed into the separate `joakimp/pi-devbox` repo as `base-pi-only` (so no opencode-less tag appears here). Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~35x slower than native).
opencode-devbox builds **two image variants** (`base`, `omos`) × **two architectures** (amd64, arm64), publishing **four tags per release** + the floating `base-latest`. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~35x slower than native).
The five variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
> pi was removed in v2.0.0; it now builds in its own `joakimp/pi-devbox` repo. Before v2.0.0 a fifth `pi-only` build was produced here and pushed into that repo as `base-pi-only` — that coupling is gone.
The two variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
Two improvements were considered:
@@ -32,8 +34,8 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
│ │ probe Docker Hub.
│ hash inputs: │ (resolve-versions
│ Dockerfile.base│ runs in parallel:
│ rootfs/ │ npm view pi/omos
│ entrypoint*.sh │ → concrete versions)
│ rootfs/ │ npm view omos
│ entrypoint*.sh │ → concrete version)
└────────┬─────────┘
┌─────────────┴─────────────┐
@@ -47,18 +49,18 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
└────────┬─────────┘ to Docker Hub.
┌───────────────────────┼───────────────────────┐
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│smoke-base│ │smoke-omos│ ... │smoke-omos-pi │ amd64 only,
└────┬─────┘ └────┬─────┘ └──────┬───────┘ parallel.
│ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│build- │ │build- │ │build- │ multi-arch,
│variant- │ │variant- │ ... │variant- parallel,
│base │ │omos │ │omos-with-pi │ tag push.
└────┬─────┘ └────┬─────┘ └──────┬───────┘
└───────────────────────┴──────────────────────┘
▼ ▼
┌──────────┐ ┌──────────┐
│smoke-base│ │smoke-omos│ amd64 only,
└────┬─────┘ └────┬─────┘ parallel.
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│build- │ │build- │ multi-arch,
│variant- │ │variant- │ parallel,
│base │ │omos │ tag push.
└────┬─────┘ └────┬─────┘
└──────────────────────┘
┌──────────────────────────┐
@@ -111,13 +113,12 @@ dependency between them) and resolves the floating npm packages whose
`*_VERSION` build-args default to `latest`:
```sh
PI_VERSION=$(npm view @earendil-works/pi-coding-agent version)
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
```
The outputs (`pi_version`, `omos_version`) are consumed by every variant
smoke and build job that installs pi or omos. **Why this exists:** without
it, the `npm install -g` RUN layer in `Dockerfile.variant` hashes
The output (`omos_version`) is consumed by the omos variant smoke and
build jobs. **Why this exists:** without it, the `npm install -g` RUN
layer in `Dockerfile.variant` hashes
identically across builds (same ARG default, same command string), so
the registry buildcache silently reuses the layer from whatever upstream
version was current when the cache was first populated. This is the
@@ -125,9 +126,9 @@ cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0
through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b
2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every
release (parent-chain cache-key invalidation), but masking would fail on
a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or
omos. Smoke jobs additionally assert `EXPECTED_PI_VERSION` /
`EXPECTED_OMOS_VERSION` against the resolved values.
a `vN.N.Nb` opencode-version-unchanged release that only bumps omos.
Smoke jobs additionally assert `EXPECTED_OMOS_VERSION` against the
resolved value.
### Step 2: `build-base` (conditional)
@@ -139,23 +140,21 @@ when only one or two layers changed.
The base image is **not** tagged `base-latest` here — that promotion
happens at the very end after all variants succeed (see step 5).
### Step 3: `smoke-*` (×4, parallel)
### Step 3: `smoke-*` (×2, parallel)
For each variant: build amd64-only against the base tag, load into
local docker, run [`scripts/smoke-test.sh`](../scripts/smoke-test.sh).
Variant build-args:
| variant | INSTALL_OPENCODE | INSTALL_OMOS | INSTALL_PI |
|---|---|---|---|
| `base` | true | false | false |
| `omos` | true | true | false |
| `with-pi` | true | false | true |
| `omos-with-pi` | true | true | true |
| variant | INSTALL_OPENCODE | INSTALL_OMOS |
|---|---|---|
| `base` | true | false |
| `omos` | true | true |
Smoke runs `--variant <name>` to enable variant-specific assertions.
Gate the publish: a smoke failure for variant X blocks `build-variant-X`.
### Step 4: `build-variant-*` (×4, parallel)
### Step 4: `build-variant-*` (×2, parallel)
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
@@ -165,8 +164,6 @@ tags:
|---|---|
| `build-variant-base` | `vX.Y.Z`, `latest` |
| `build-variant-omos` | `vX.Y.Z-omos`, `latest-omos` |
| `build-variant-with-pi` | `vX.Y.Z-with-pi`, `latest-with-pi` |
| `build-variant-omos-with-pi` | `vX.Y.Z-omos-with-pi`, `latest-omos-with-pi` |
The `latest*` aliases are only updated when `promote_latest=true` (the
manual dispatch input) — for test runs, `promote_latest=false` keeps the
@@ -174,7 +171,7 @@ production aliases pointing at the previous good release.
### Step 5: `promote-base-latest`
Once all five variants successfully publish, re-tag `base-<hash>` as
Once both variants successfully publish, re-tag `base-<hash>` as
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
a rebuild** — it touches only Docker Hub's image index, takes seconds,
and is atomic.
@@ -182,7 +179,7 @@ and is atomic.
The reason this happens *after* variants succeed (rather than alongside
`build-base`) is so a partial failure leaves `base-latest` pointing at
the previous known-good base. External consumers who pin to
`base-latest` (e.g. the planned pi-devbox repo) never see a broken base.
`base-latest` never see a broken base.
### Step 6: `update-description`
@@ -194,18 +191,21 @@ field via the Hub REST API. Same step as the production pipeline.
The base sets
```
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global
ENV NPM_CONFIG_PREFIX=/home/developer/.config/opencode/npm-global
```
This is intentional — it makes `pi install npm:<pkg>` and `npm install -g`
land on the `devbox-pi-config` named volume at runtime, so user-installed
packages survive container recreate AND image rebuild.
This is intentional — it makes `npm install -g` land on the
`devbox-opencode-config` named volume at runtime, so user-installed
packages survive container recreate AND image rebuild. (Before v2.0.0
this prefix lived at `~/.pi/npm-global` on the now-removed
`devbox-pi-config` volume; `entrypoint-user.sh` migrates the old path
once.)
But the *variant build* inherits this prefix at build time. If left as-is,
`npm install -g opencode-ai@$VERSION` in `Dockerfile.variant` would
install opencode into `/home/developer/.pi/npm-global/...`, which is then
**shadowed by the volume mount at runtime** → opencode disappears from
PATH on first start.
install opencode into `/home/developer/.config/opencode/npm-global/...`,
which is then **shadowed by the volume mount at runtime** → opencode
disappears from PATH on first start.
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
per-RUN:
@@ -216,7 +216,7 @@ RUN NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION}
Baked binaries land on `/usr/bin/...` (system prefix), survive the volume
mount. Runtime-installed user packages still land on
`~/.pi/npm-global/...`. Both visible on PATH.
`~/.config/opencode/npm-global/...`. Both visible on PATH.
## Cache strategy
@@ -238,7 +238,7 @@ matters more.
| Scenario | Production pipeline | Split-base pipeline |
|---|---|---|
| Version-bump-only release (only opencode/pi/omos version changed) | ~165180 min | **~3040 min** (base cache hit) |
| Version-bump-only release (only opencode/omos version changed) | ~165180 min | **~3040 min** (base cache hit) |
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165180 min | **~7090 min** (base rebuilds) |
The split-base pipeline pays its dues on base-touching releases (which are
@@ -252,7 +252,7 @@ on every push to `main` and on PRs. It:
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
2. Builds each of the five variants amd64-only (no multi-arch, no push)
2. Builds each of the two variants amd64-only (no multi-arch, no push)
and runs `scripts/smoke-test.sh`.
This catches regressions before they reach a tag push. Wall clock ~30 min.
+37 -431
View File
@@ -7,10 +7,10 @@ name: Publish Docker Image
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
# + entrypoints; probe Docker Hub for existing tag.
# 2. build-base only if probe missed; multi-arch push of base-<hash>.
# 3. smoke-* (×4) amd64-only build of each variant FROMing the base
# 3. smoke-* (×2) amd64-only build of each variant FROMing the base
# tag; runs scripts/smoke-test.sh.
# 4. build-variant-* multi-arch push of each variant tag (the user-
# (×4) facing release tags, unchanged in shape).
# (×2) facing release tags, unchanged in shape).
# 5. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`
# (manifest copy, no rebuild).
# 6. update-description patch Docker Hub description (unchanged).
@@ -37,11 +37,6 @@ concurrency:
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
# The pi-only variant is built here (single source of truth for the pi stack)
# but published into the pi-devbox repo as an internal building-block tag,
# NOT under opencode-devbox — so opencode-devbox never shows a tag with no
# opencode in it. pi-devbox's own CI FROMs PI_IMAGE:base-pi-only.
PI_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 }}
@@ -107,30 +102,27 @@ jobs:
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 1b: resolve floating npm versions (pi, omos) to concrete
# ── Phase 1b: resolve floating npm versions (omos) to concrete
# versions so the variant build-args carry a different value when an
# upstream package bumps. Without this, when PI_VERSION / OMOS_VERSION
# default to 'latest', the docker/build-push-action build-arg string
# is byte-identical across builds, so the resulting layer-hash is
# identical, so the registry buildcache silently reuses the layer
# from whatever pi/omos 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). Currently masked here because OPENCODE_VERSION
# is hard-coded in Dockerfile.variant and bumps every release —
# invalidating the parent-chain cache key for the pi/omos layers — but
# that masking would fail the moment we cut a vN.N.Nb opencode-version-
# unchanged release that only bumps pi or omos. Fix is preventative.
# upstream package bumps. Without this, when OMOS_VERSION defaults to
# 'latest', the docker/build-push-action build-arg string is byte-
# identical across builds, so the resulting layer-hash is identical,
# so the registry buildcache silently reuses the layer from whatever
# omos 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). Currently masked because OPENCODE_VERSION is hard-coded
# in Dockerfile.variant and bumps every release — invalidating the
# parent-chain cache key for the omos layer — but that masking would
# fail the moment we cut a vN.N.Nb opencode-version-unchanged release
# that only bumps omos. Fix is preventative.
resolve-versions:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
omos_version: ${{ steps.resolve.outputs.omos_version }}
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
steps:
- name: Resolve pi + omos versions from npm registry
- name: Resolve omos version from npm registry
id: resolve
run: |
set -eu
@@ -139,27 +131,9 @@ jobs:
# and adds it to PATH only via /etc/environment — which act_runner never
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
# curl and jq are both guaranteed present in every job in this workflow.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
# Resolve the pi-fork / pi-observational-memory git refs (default
# branch master) to concrete commit SHAs so the build-arg string
# changes whenever upstream moves — defeating the same registry-
# buildcache cache-hit footgun that PI_VERSION/OMOS_VERSION guard
# against. The Accept: application/vnd.github.sha media type returns
# the bare SHA. Falls back to the branch name if the API is
# unreachable/rate-limited (still functional, just cache-stale-prone).
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}, OMOS_VERSION=${OMOS_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
@@ -292,7 +266,6 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
@@ -335,158 +308,11 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
- env:
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
smoke-with-pi:
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
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
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 }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
STRICT_REGISTRATION: "1"
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
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
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
STRICT_REGISTRATION: "1"
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
smoke-pi-only:
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
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-pi-only
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=false
INSTALL_OMOS=false
INSTALL_PI=true
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 }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
STRICT_REGISTRATION: "1"
run: bash scripts/smoke-test.sh opencode-devbox:smoke-pi-only --variant pi-only
# ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base:
@@ -544,7 +370,6 @@ jobs:
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "INSTALL_PI=false" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
@@ -614,7 +439,6 @@ jobs:
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "INSTALL_PI=false" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
"${TAG_FLAGS[@]}" \
.; then
@@ -630,245 +454,12 @@ jobs:
echo "==> All 3 build+push attempts failed"
exit 1
build-variant-with-pi:
needs: [base-decide, smoke-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-with-pi"
fi
echo "EOF"
} >> "$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). Variant: with-pi.
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 "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "INSTALL_PI=true" \
--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
build-variant-omos-with-pi:
needs: [base-decide, smoke-omos-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
fi
echo "EOF"
} >> "$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 }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_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). Variant: omos-with-pi.
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 "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "INSTALL_PI=true" \
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "OMOS_VERSION=${OMOS_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
build-variant-pi-only:
needs: [base-decide, smoke-pi-only, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
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
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
# Option B: push the pi-only build into the pi-devbox repo as an
# internal building-block tag (base-pi-only[-<version>]), NOT under
# opencode-devbox. pi-devbox's CI FROMs ${PI_IMAGE}:base-pi-only.
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${PI_IMAGE}:base-pi-only-${VERSION}"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${PI_IMAGE}:base-pi-only"
fi
echo "EOF"
} >> "$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). Variant: pi-only.
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 "INSTALL_OPENCODE=false" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "INSTALL_PI=true" \
--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
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
promote-base-latest:
needs:
- base-decide
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
- build-variant-pi-only
# Skip on cache-hit base builds: when need_build=false, base-latest
# already points at the same digest as base-<hash>, so the retag is
# a tautology and any transient failure of it is purely cosmetic.
@@ -877,7 +468,7 @@ jobs:
#
# `always()` wrapper + explicit base-variant success check protects
# against the gitea-Actions default of "skipped need => skip dependent":
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
# a partial-publish run (e.g., omos smoke fails) shouldn't
# prevent the base-latest alias from advancing on a real base rebuild.
if: |
always() &&
@@ -919,11 +510,8 @@ jobs:
needs:
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
- build-variant-pi-only
# Run when at least the base variant published — don't let a single
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
# variant failure (e.g., omos smoke threshold) prevent Hub
# description refresh for the other variants that did publish.
# Without this `always()` wrapper, gitea Actions' default behavior
# of "skipped need => skip dependent" cascades from any failed/
@@ -940,6 +528,24 @@ jobs:
- uses: actions/checkout@v4
- name: Update Docker Hub description
run: |
# Substitute {{OPENCODE_VERSION}} placeholders in DOCKER_HUB.md so
# the Hub page always shows which opencode version is baked into
# :latest. The placeholder lives in DOCKER_HUB.md (committed); CI
# fills it at publish time from the pinned ARG in
# Dockerfile.variant — the same value that was baked into the
# image — so the page and the image never drift. (Mirrors the
# {{PI_VERSION}} pattern in pi-devbox's docker-publish.yml.)
OPENCODE_VERSION=$(sed -n 's/^ARG OPENCODE_VERSION=//p' Dockerfile.variant | head -1)
if [ -z "${OPENCODE_VERSION}" ]; then
echo "::error::Could not extract OPENCODE_VERSION from Dockerfile.variant"
exit 1
fi
cp DOCKER_HUB.md /tmp/hub-full.md
sed -i "s/{{OPENCODE_VERSION}}/${OPENCODE_VERSION}/g" /tmp/hub-full.md
if grep -q '{{OPENCODE_VERSION}}' /tmp/hub-full.md; then
echo "::error::DOCKER_HUB.md still contains unsubstituted {{OPENCODE_VERSION}} markers"
exit 1
fi
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
@@ -949,7 +555,7 @@ jobs:
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--rawfile full /tmp/hub-full.md \
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
-184
View File
@@ -20,14 +20,6 @@ name: Validate
# release tags are the gate that fully validates base-image changes.
# The base-change-warning job below surfaces a runtime warning when this
# blind-spot applies.
#
# Because of this, the fork/recall *registration* smoke checks (which depend on
# the base entrypoint running `pi install /opt/<pkg>`) are warn-only here:
# smoke-test.sh leaves STRICT_REGISTRATION unset on this path, so a base-latest
# that lags the entrypoint in the current commit can't red the run with a false
# negative. The release smoke jobs build the base fresh and set
# STRICT_REGISTRATION=1 to enforce those checks. The build-time /opt +
# node_modules checks stay hard in both paths.
on:
push:
@@ -203,179 +195,3 @@ jobs:
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos --variant omos
validate-with-pi:
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 df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_PI=true
tags: opencode-devbox:ci-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-with-pi --variant with-pi
validate-omos-with-pi:
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 df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build omos+with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OMOS=true
INSTALL_PI=true
tags: opencode-devbox:ci-omos-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
validate-pi-only:
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 df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build pi-only image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OPENCODE=false
INSTALL_PI=true
tags: opencode-devbox:ci-pi-only
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-pi-only --variant pi-only
+45 -35
View File
@@ -4,21 +4,21 @@
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
> **pi is deprecated here (since v1.17.2), removed in v2.0.0.** The
> `INSTALL_PI` build arg, the `with-pi` / `omos-with-pi` / `pi-only`
> variants, the `base-pi-only` published tag, and all `~/.pi`-related
> wiring are slated for removal. pi now ships from its own repo
> (`joakimp/pi-devbox`). Do not add new pi functionality here. Full
> removal plan + the `NPM_CONFIG_PREFIX` relocation: see
> `docs/CLEANUP-v2.0.0.md`. The pi-related descriptions below remain
> accurate only until the v2.0.0 removal lands.
> **pi was removed in v2.0.0** (deprecated since v1.17.2). The `INSTALL_PI`
> build arg, the `with-pi` / `omos-with-pi` / `pi-only` variants, the
> `base-pi-only` published tag, and all `~/.pi`-related wiring are gone. pi
> now ships from its own repo (`joakimp/pi-devbox`). Do not add pi
> functionality here. The removal history + the `NPM_CONFIG_PREFIX`
> relocation (`~/.pi/npm-global` → `~/.config/opencode/npm-global`, with a
> one-time migration shim in `entrypoint-user.sh`) are recorded in
> `docs/CLEANUP-v2.0.0.md` and the v2.0.0 CHANGELOG entry.
## File roles
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
- `Dockerfile.variant``FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. It is built and smoke-tested here, but **published into the `joakimp/pi-devbox` repo** as the internal building-block tag `base-pi-only[-vX.Y.Z]` (NOT under `opencode-devbox`), so an opencode-devbox tag never ships without opencode.
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup.
- `Dockerfile.variant``FROM`s the base and adds only opencode/omos installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. Two variants: `base` (`INSTALL_OPENCODE=true`) and `omos` (`+INSTALL_OMOS=true`).
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), a one-time npm-global prefix migration shim (legacy `~/.pi/npm-global``~/.config/opencode/npm-global`), skillset auto-deploy from mounted skillset repo, OMOS bundled-skills reconcile (symlinks the image's bundled skills into `~/.agents/skills/`), OMOS config setup.
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
@@ -27,25 +27,40 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
- `.gitea/README.md`**read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 5 parallel smoke tests, then 5 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
## Versioning scheme
Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first build on a new opencode release, and `v1.14.20b`, `v1.14.20c`, … for subsequent rebuilds on the same opencode version.
Image tags follow **independent semver** — they version *this image*, not the
bundled opencode release. **`v2.0.0` is the decoupling point** (the pi-removal
breaking release); from there the opencode npm version is tracked in
`CHANGELOG.md` and the `OPENCODE_VERSION` ARG but no longer drives the tag. This
mirrors the sibling [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)
repo, which decoupled from the pi tool version at its own `v1.0.0`.
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile.variant`).
- **No letter suffix** on the first build of a new opencode version — the bare `v{opencode_version}` tag is the canonical release.
- **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on.
- A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version.
- **Pre-flight check before cutting any non-letter-suffixed tag** — verify the bump is real:
- **MAJOR** — breaking changes to how users run/configure the container (volume
layout, removed variants/build-args, an entrypoint contract change that
requires user action). `v2.0.0` (pi removal + npm-prefix relocation) is the
reference example.
- **MINOR** — backward-compatible features: new variants/tags, new opt-in
behavior, new env vars, or changed-but-compatible semantics. Example: `v2.1.0`
added the OMOS bundled-skills image-symlink mechanism.
- **PATCH** — opencode/tool version bumps and small fixes that don't change the
contract. When a release pairs a tool bump with a feature, the feature wins
and it's a minor.
- **Pre-flight check** — whenever an opencode bump is part of the release,
verify it is real before claiming it in the CHANGELOG:
```bash
npm view opencode-ai version # must equal the X.Y.Z in your tag
npm view opencode-ai version # must equal the X.Y.Z you pin in Dockerfile.variant
```
If the npm version equals the *previous* release's `X.Y.Z`, you're cutting a letter-suffix rebuild (`vX.Y.Zc`, `vX.Y.Zd`, …), not a new minor. **A bare `vX.Y.Z` tag is a claim that opencode upstream just released `X.Y.Z`** — if that claim is wrong, future opencode releases will collide with your tag namespace and the version-tracking story breaks.
Historical note: under the *old* `v{opencode_version}[letter]` scheme a
mismatched tag was a namespace hazard — e.g. `v1.15.12` was cut while
opencode was still `1.15.11`, then re-cut as `v1.15.11c` (2026-05-28), costing
a CI cycle. Semver tags no longer encode the opencode version, so that
specific collision class is gone — but a CHANGELOG that names the wrong
upstream version is still wrong.
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
CI produces eight Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per opencode-bearing variant (four variants). A fifth build, `pi-only`, is built+smoked here but pushed into the **`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ `base-pi-only` on tag builds), where it becomes the base for that image.
CI produces four Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z`, `latest`, `vX.Y.Z-omos`, `latest-omos` — one tag pair (versioned + floating alias) per variant (two variants: `base`, `omos`).
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
@@ -56,7 +71,6 @@ When drafting a release CHANGELOG entry, pull notes from the **canonical upstrea
| Package | Canonical upstream | What you'll find there |
|---|---|---|
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| `@earendil-works/pi-coding-agent` (npm) | The `CHANGELOG.md` shipped inside the npm tarball: `npm pack @earendil-works/pi-coding-agent@<version>` then extract `package/CHANGELOG.md`. | Rich changelog with New Features / Added / Changed / Fixed sections per version. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes.
@@ -69,9 +83,6 @@ npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(s
# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
# pi changelog
cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-works-pi-coding-agent-0.75.5.tgz package/CHANGELOG.md && head -40 package/CHANGELOG.md
```
## Critical conventions
@@ -89,31 +100,30 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-
**Between releases the same coupling applies.** Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or `generate-config.py` refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example`. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit.
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
- **`OMOS_VERSION` MUST be passed by CI as a concrete version**, not left at the `latest` default. The npm install step in `Dockerfile.variant` (`oh-my-opencode-slim@${OMOS_VERSION}`) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view oh-my-opencode-slim version`, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the `EXPECTED_OMOS_VERSION` env var — would catch the regression on the next release rather than several releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
- **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH` → `$HOME/skillset` → `/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts.
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. **Same pattern for pi:** `devbox-pi-config` is mounted at `~/.pi/` and persists user toggles (`/ext`-disabled extensions), `~/.pi/agent/settings.json` edits, and — because `NPM_CONFIG_PREFIX` is set to `~/.pi/npm-global` — anything installed via `pi install npm:...` or `npm install -g` as the developer user, across container recreate AND image rebuild.
- **pi install contract** — `INSTALL_PI=true` (default false) opt-in build arg. The baked `pi` binary is npm-installed globally to `/usr` at build time (system prefix). At runtime, `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` is set in the image ENV with that prefix's `bin/` prepended to `PATH` — so any `pi install npm:...` or `npm install -g` invoked by the developer user lands on the named volume and survives everything except `docker compose down -v`. The new ENVs are declared *after* all build-time `npm install -g` calls in the Dockerfile so they don't redirect the baked installs into a path that the volume mount would later shadow. If the user runs `npm install -g @earendil-works/pi-coding-agent` themselves, the user-installed copy on the volume wins via `PATH` order; otherwise image rebuild is the upgrade path for the baked pi (same contract as `OPENCODE_VERSION`). The pi-toolkit and pi-extensions repos are git-cloned into `/opt/` at build time, then their `install.sh` runs from `entrypoint-user.sh` on each container start to symlink into `~/.pi/agent/` (which lives on the named volume). The mempalace pi-bridge is symlinked manually from `/opt/mempalace-toolkit/extensions/pi/mempalace.ts` — we do NOT call mempalace-toolkit's full `install.sh` because its `install_skill` step would race with skillset auto-deploy `--prune-stale`.
- **Pi deploy ordering matters in entrypoint-user.sh** — `pi-toolkit` runs first (creates `keybindings.json` symlink and writes pi-env.zsh), then `pi-extensions`, then `settings.json` template bootstrap, then mempalace bridge symlink. mempalace-toolkit's `check_pi_toolkit` probe (when called from the host install path) expects keybindings to already be present — not currently called from container, but ordering matches host convention.
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` or `pi` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode` / `docker compose run --rm devbox pi`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
- **OMOS bundled-skills reconcile** — on the omos variant, `entrypoint-user.sh` symlinks the five skills bundled with `oh-my-opencode-slim` (`clonedeps`, `codemap`, `deepwork`, `oh-my-opencode-slim`, `simplify`) from the image path `/usr/lib/node_modules/oh-my-opencode-slim/src/skills/<name>` into `~/.agents/skills/`, on **every** start, *after* the skillset deploy (so OMOS wins name collisions via `ln -sfn` — the only overlap is `simplify`, which was removed from the skillset repo). These are **absolute** symlinks (target is image-internal at a fixed `/usr` path) — do NOT "fix" them to relative like skillset's. Because the target lives in the image, pulling a newer image updates the skills with no installer run and no config reset. The block is non-fatal (`{ … } || true`), gated by `OMOS_SKILLS` (default true, **independent of `ENABLE_OMOS`**) and the presence of the source dir (no-op on the base variant). The two `oh-my-opencode-slim install` calls now pass `--skills=no` unconditionally — the installer manages only `oh-my-opencode-slim.json`, never skills; do not reintroduce installer-managed skills. A one-time migration (marker: `~/.config/opencode/.omos-skills-migrated`) backs up — never deletes — any frozen real copies the old installer left in `~/.config/opencode/skills/` to `<name>.bak.<epoch>`, because those would otherwise shadow the fresh image-sourced symlinks. The build-time smoke test asserts the bundled-skills source path exists (catches an upstream package restructure loudly). Full rationale: `docs/omos-skills.md`.
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. Because `NPM_CONFIG_PREFIX` is set to `~/.config/opencode/npm-global` (relocated from the legacy `~/.pi/npm-global` in v2.0.0), anything installed via `npm install -g` as the developer user also lands on this volume and survives container recreate AND image rebuild.
- **npm-global prefix relocation (v2.0.0 breaking change)** — the user-writable global npm prefix moved from `~/.pi/npm-global` to `~/.config/opencode/npm-global`. The old path lived on the `devbox-pi-config` volume (only mounted in `docker-compose.yml`); the new path is on `devbox-opencode-config`, which is a persistent named volume in BOTH `docker-compose.yml` and `docker-compose.shared.yml`. `entrypoint-user.sh` carries a one-time migration shim: if `~/.pi/npm-global` exists and the marker `~/.config/opencode/npm-global/.migrated-from-dot-pi` is absent, it `cp -an` the old `lib/`/`bin/`/`share/` into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on `/usr` (the variant Dockerfile runs each `npm install -g` with `NPM_CONFIG_PREFIX=/usr`) so the volume mount doesn't shadow them. The `ENV NPM_CONFIG_PREFIX`/`PATH` lines in `Dockerfile.base` are declared *after* all build-time installs.
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
## CI quirks
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
- `update-description` job runs when the base variant published (`needs: [build-variant-base, build-variant-omos]`, gated with `always()` + an explicit `build-variant-base.result == 'success'` check so a partial-publish run still refreshes the Hub description).
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All ten `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `validate-pi-only`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `smoke-pi-only`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
- **`STRICT_REGISTRATION` gates the fork/recall *registration* smoke assertions.** `smoke-test.sh`'s two pi-extension registration checks (that `pi-fork`/`pi-observational-memory` registered in `~/.pi/agent/settings.json`) depend on the *base* entrypoint running `pi install /opt/<pkg>`. `validate.yml` builds variants from the **published** `base-latest`, which lags the in-repo entrypoint until a release rebuilds the base — so those checks would false-negative there. They are therefore warn-only unless `STRICT_REGISTRATION=1`: `validate.yml` leaves it unset (warn), and `docker-publish-split.yml` (which builds the base fresh in the same run) sets `STRICT_REGISTRATION: "1"` on the three pi-bearing smoke jobs to enforce them. Build-time `/opt` + `node_modules` checks stay hard in both paths. If you touch the registration checks or the base-freshness model, keep this flag wiring in lockstep across both workflows.
## Testing changes
+128
View File
@@ -8,6 +8,134 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
## Unreleased
### Docs (no image change)
- Fix the quick-start description in `README.md` and the Hub `HUB_TEMPLATE`
(`scripts/generate-dockerhub-md.py`, regenerated `DOCKER_HUB.md`): bare
`docker compose run --rm devbox` lands in a **login shell** (default `CMD` is
`bash -l`), not opencode. The old copy claimed the opposite and had a garbled
"Use `bash` instead of (no command)" half-sentence. Pass `opencode` explicitly
to start the harness directly. Doc-only — does not trigger a new image build.
## v2.1.0 — 2026-06-13
Image-semver **minor**: adds the OMOS bundled-skills image-symlink mechanism and
bumps opencode. `v2.0.0` decoupled image versioning from the opencode version
(see [AGENTS.md](AGENTS.md#versioning-scheme)); this is the first feature release
on the semver line, mirroring how the sibling `pi-devbox` repo moved to semver
after its own `v1.0.0` decouple.
### Bumped: opencode-ai 1.17.4 → 1.17.5
`OPENCODE_VERSION` ARG in `Dockerfile.variant`.
### Changed: OMOS bundled skills are now symlinked from the image, not copied
The five skills bundled with `oh-my-opencode-slim` (`clonedeps`, `codemap`,
`deepwork`, `oh-my-opencode-slim`, `simplify`) are now **symlinked from the
image** into `~/.agents/skills/` on every container start, instead of being
**copied** into `~/.config/opencode/skills/` once on first run by the OMOS
installer.
Why: the old copy landed in the persistent `devbox-opencode-config` volume and
was gated by config-existence, so it **froze** at whatever the image shipped on
first run — pulling a newer image never refreshed the skills, and the only
update path (`OMOS_RESET=true`) also clobbered the user's hand-tuned
`opencode.jsonc`. With the symlink approach the link target lives in the image,
so **`docker compose pull` + recreate updates the skills for free** — no
installer run, no config reset.
- `entrypoint-user.sh`: new OMOS bundled-skills reconcile block (runs after the
skillset deploy so OMOS wins the `simplify` name collision; absolute symlinks;
non-fatal; gated by `OMOS_SKILLS`, now independent of `ENABLE_OMOS` and the
presence of the bundled-skills source on the omos variant).
- `entrypoint-user.sh`: both `oh-my-opencode-slim install` calls now pass
`--skills=no` — the installer manages only `oh-my-opencode-slim.json`.
- One-time migration backs up (never deletes) any frozen real copies in
`~/.config/opencode/skills/` to `<name>.bak.<epoch>` (marker:
`~/.config/opencode/.omos-skills-migrated`).
- `scripts/smoke-test.sh`: asserts the bundled-skills source path exists on the
omos variant (catches an upstream package restructure).
- Docs: new `docs/omos-skills.md`; updated `README.md`, `AGENTS.md`,
`.env.example` (`OMOS_SKILLS` semantics).
**Editing `entrypoint-user.sh` advances the base hash** — this release carries a
full base rebuild and a `base-latest` re-promote.
## v2.0.0 — 2026-06-13
**Major release: pi is fully removed from opencode-devbox** (deprecated in
v1.17.2), and the user-writable global npm prefix is relocated off the
pi-specific `~/.pi` path. Also bumps opencode. The major version signals the
two breaking changes below.
### Bumped: opencode-ai 1.17.2 → 1.17.4
`OPENCODE_VERSION` ARG in `Dockerfile.variant`.
### Removed: all pi support
pi ships as its own self-contained image,
[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox). Everything
pi-related is now gone from this repo:
- The `INSTALL_PI` build arg and all `PI_*` args (`PI_VERSION`,
`PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF`, `PI_FORK_*`, `PI_OBSMEM_*`).
- The `with-pi`, `omos-with-pi`, and `pi-only` build variants. **Only `base`
and `omos` remain** — four published tags per release (`vX.Y.Z`, `latest`,
`vX.Y.Z-omos`, `latest-omos`).
- The `base-pi-only[-vX.Y.Z]` tag that this repo's CI published into the
`joakimp/pi-devbox` repo. The publisher job is deleted; the orphaned
`base-pi-only*` tags on the pi-devbox Hub repo can now be purged (see
`docs/CLEANUP-v2.0.0.md`).
- All `~/.pi` entrypoint wiring (pi-toolkit / pi-extensions deploy,
settings.json bootstrap, mempalace pi-bridge symlink, pi-fork / pi-obsmem
registration), the `~/.pi` volume-ownership entry, and the three pi
smoke / validate / build-variant CI jobs.
**Migration:** pull `joakimp/pi-devbox:latest` instead of any `*-with-pi` /
`pi-only` opencode-devbox tag. opencode-only users are unaffected by the pi
removal itself.
### Breaking: global npm prefix relocated `~/.pi/npm-global` → `~/.config/opencode/npm-global`
`NPM_CONFIG_PREFIX` (and the matching `PATH` entry) moved off the
pi-specific path. The new location lives on the `devbox-opencode-config`
named volume, which — unlike the old `devbox-pi-config` — is a **persistent
named volume in both `docker-compose.yml` and `docker-compose.shared.yml`**,
so runtime `npm install -g` still survives container recreate and image
rebuild.
**Impact on upgraders:** any tool you previously `npm install -g`'d landed in
`~/.pi/npm-global` and will **drop off `PATH`** under v2.0.0.
**Mitigation (automatic):** `entrypoint-user.sh` carries a one-time
migration shim. On first start, if `~/.pi/npm-global` exists and the marker
`~/.config/opencode/npm-global/.migrated-from-dot-pi` is absent, it copies
the old `lib/`/`bin/`/`share/` contents into the new prefix (never
overwriting freshly-installed packages) and writes the marker. For the shim
to see your old packages, the legacy `devbox-pi-config` volume must still be
mounted at `~/.pi` for that first start — the shipped `docker-compose.yml`
leaves it commented with instructions; uncomment the mount for one start if
you had global npm tools to migrate, then remove it. Fresh installs carry no
`~/.pi` dead weight. If you don't need the old packages, ignore all of this
and re-`npm install -g` anything you want.
### Docs / CI
- `DOCKER_HUB.md` now shows the baked opencode version via a
`{{OPENCODE_VERSION}}` placeholder substituted by CI at publish time
(mirrors pi-devbox's `{{PI_VERSION}}` pattern) — the Hub page can no longer
drift from the image.
- README, AGENTS.md, `.gitea/README.md`, `.env.example`, and the
`docs/manual-host-publish.*` runbook updated to the two-variant reality
(variant tables, CI job lists, the npm-prefix gotcha, ASCII pipeline
diagram, tag counts).
- CI: removed the three pi smoke jobs, three pi build-variant jobs, the
`pi-only` publish-to-pi-devbox job, and the pi/fork/obsmem resolution in
`resolve-versions` (now resolves omos only). `validate.yml` drops its
three pi validate jobs.
## v1.17.2 — 2026-06-10
First container build on **opencode-ai `1.17.2`** (from `1.16.2`). This
+14 -16
View File
@@ -2,24 +2,24 @@
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
> **Current `:latest` ships opencode `{{OPENCODE_VERSION}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest` / `vX.Y.Z` | Base image — opencode `{{OPENCODE_VERSION}}`, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | **DEPRECATED (removed in v2.0.0)** — Base + [pi](https://github.com/earendil-works/pi). Use [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) instead |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | **DEPRECATED (removed in v2.0.0)** — OMOS + pi together |
All variants support `linux/amd64` and `linux/arm64`.
> **Looking for pi?** The `*-with-pi` / `pi-only` builds and the `base-pi-only`
> tag are **deprecated since v1.17.2 and will be removed in v2.0.0**. pi now
> ships as its own self-contained image:
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox). Pull that
> directly instead of any pi-bearing opencode-devbox tag.
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
> opencode-devbox. It ships as the dedicated
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
> shares the same mempalace memory layer. See
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
## Quick Start
@@ -34,7 +34,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:**
@@ -54,11 +54,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
## What's Inside
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
## Authentication
@@ -76,8 +75,7 @@ https://gitea.jordbo.se/joakimp/opencode-devbox#aws-bedrock-authentication
| Volume | Mount | Survives |
|---|---|---|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild — incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
@@ -94,7 +92,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
@@ -102,4 +100,4 @@ MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENS
---
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{OPENCODE_VERSION}}` placeholder is filled by CI at publish time.
+23 -16
View File
@@ -1,15 +1,14 @@
# opencode-devbox — base image (variant-independent layers)
#
# This Dockerfile produces an image tagged base-<hash>, used as the parent
# for all four published variants (base, omos, with-pi, omos-with-pi).
# It contains everything that does not depend on variant-specific
# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant
# Dockerfile (Dockerfile.variant) FROMs the base and adds only those
# deltas.
# for all published variants (base, omos). It contains everything that
# does not depend on variant-specific build-args (INSTALL_OPENCODE,
# INSTALL_OMOS). The variant Dockerfile (Dockerfile.variant) FROMs the
# base and adds only those deltas.
#
# The base is rebuilt only when this file or anything it COPYs in
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
# OMOS_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
@@ -375,7 +374,7 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
# Create standard directories
RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.pi/agent/extensions \
/home/${USER_NAME}/.config/opencode/npm-global \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.local/share/opencode \
/home/${USER_NAME}/.cache/bash \
@@ -395,20 +394,28 @@ 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 ──
# ── User-writable npm global prefix on the devbox-opencode-config volume ──
# By default npm's global prefix is /usr (writable only by root) so any
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
# developer user would EACCES. Pointing the prefix into ~/.pi places
# user-installed packages on the named volume, which means they survive
# container recreation AND image rebuilds.
# `npm install -g <pkg>` invoked by the developer user would EACCES.
# Pointing the prefix into ~/.config/opencode places user-installed
# packages on the devbox-opencode-config named volume, which means they
# survive container recreation AND image rebuilds.
#
# NOTE (v2.0.0): this prefix previously lived at ~/.pi/npm-global — a
# pi-specific path. With pi removed (see docs/CLEANUP-v2.0.0.md) it now
# lives under ~/.config/opencode, which is a persistent named volume in
# BOTH docker-compose.yml and docker-compose.shared.yml (the old ~/.pi
# volume was only in the former). A one-time migration shim in
# entrypoint-user.sh copies any existing ~/.pi/npm-global contents to the
# new prefix on first start so user-installed globals are not lost.
#
# IMPORTANT: in this split-build layout the variant Dockerfile inherits
# this prefix at build time. To keep the baked binaries on /usr (so the
# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST
# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# volume mount doesn't shadow them), the variant Dockerfile MUST run each
# `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# environment. See Dockerfile.variant.
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.config/opencode/npm-global
ENV PATH="/home/${USER_NAME}/.config/opencode/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
RUN mkdir -p /etc/skel-devbox
+22 -106
View File
@@ -3,37 +3,29 @@
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
# the variant-specific tools (opencode, pi, oh-my-opencode-slim, Go).
#
# The four published variants are produced from THIS Dockerfile by
# The two published variants are produced from THIS Dockerfile by
# varying build args:
#
# variant INSTALL_OPENCODE INSTALL_OMOS INSTALL_PI
# ───────────────── ──────────────── ──────────── ──────────
# base true false false
# omos true true false
# with-pi *DEPR* true false true
# omos-with-pi*DEPR* true true true
# pi-only *DEPR* false false true
# variant INSTALL_OPENCODE INSTALL_OMOS
# ──────── ──────────────── ────────────
# base true false
# omos true true
#
# DEPRECATION (since v1.17.2): the three pi-bearing variants (with-pi,
# omos-with-pi, pi-only) and the INSTALL_PI build path are DEPRECATED and
# will be REMOVED in v2.0.0. pi now ships from its own self-contained image:
# joakimp/pi-devbox:latest (https://gitea.jordbo.se/joakimp/pi-devbox).
# See docs/CLEANUP-v2.0.0.md for the removal plan.
#
# Until v2.0.0 the `pi-only` variant remains the source of truth for the
# legacy pi build (pi + companions, no opencode); pi-devbox v1.0.0+ no
# longer FROMs it.
# pi was removed in v2.0.0 (it had been deprecated since v1.17.2). It now
# ships from its own self-contained image: joakimp/pi-devbox:latest
# (https://gitea.jordbo.se/joakimp/pi-devbox). See docs/CLEANUP-v2.0.0.md
# for the removal history.
#
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
# The CI workflow 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`.
# /home/developer/.config/opencode/npm-global so runtime `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}
@@ -47,91 +39,12 @@ ARG USER_NAME=developer
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
# v0.75.5 cannot apply here.
ARG INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.17.2
ARG OPENCODE_VERSION=1.17.5
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
fi
# ── Optional: pi coding-agent ────────────────────────────────────────
# 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`,
# see .gitea/workflows/docker-publish-split.yml § resolve-versions).
# The default `latest` is for local dev convenience only — it has a
# known cache-hit footgun when used 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. Currently masked here because OPENCODE_VERSION (a
# parent layer) bumps every release; will manifest the moment a
# vN.N.Nb opencode-version-unchanged release ships. See pi-devbox
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
ARG INSTALL_PI=false
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. Refs default to the tracked branch for local dev; CI resolves
# them to concrete commit SHAs (see resolve-versions in docker-publish-split.yml)
# so the build-arg string changes when upstream moves — same registry-buildcache
# cache-hit footgun the PI_VERSION/OMOS_VERSION pins guard against. The clone
# helper for these uses `git fetch <ref>` (not `--branch`) so it accepts both
# branch names and raw commit SHAs.
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 if [ "${INSTALL_PI}" = "true" ]; then \
set -e && \
printf '%s\n' \
"===========================================================" \
"DEPRECATION WARNING: INSTALL_PI is deprecated in opencode-devbox" \
"(since v1.17.2) and will be REMOVED in v2.0.0. Use the dedicated" \
"image joakimp/pi-devbox:latest instead." \
"See https://gitea.jordbo.se/joakimp/pi-devbox" \
"===========================================================" >&2 && \
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)" ; \
fi
# ── Optional: Go ─────────────────────────────────────────────────────
ARG INSTALL_GO=false
ARG GO_VERSION=latest
@@ -151,10 +64,13 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
# Installs Bun runtime and the oh-my-opencode-slim npm package.
# OMOS_VERSION shares the same cache-hit footgun as PI_VERSION when
# left at the `latest` default in registry-cached CI builds. CI
# resolves it via `npm view oh-my-opencode-slim version` and passes
# the concrete value as a build-arg. See PI_VERSION block above.
# OMOS_VERSION has a cache-hit footgun when left at the `latest` default
# in registry-cached CI builds: the resulting build-arg string is byte-
# identical across builds, so the layer-hash is identical, so the
# registry buildcache silently reuses the layer from whatever omos
# version was current when the cache was first populated. CI resolves it
# via `npm view oh-my-opencode-slim version` and passes the concrete
# value as a build-arg (see resolve-versions in docker-publish-split.yml).
ARG INSTALL_OMOS=false
ARG OMOS_VERSION=latest
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
+15 -101
View File
@@ -2,6 +2,8 @@
Portable AI developer environment in a Docker container. Run [opencode](https://opencode.ai) on any Docker-capable machine with configurable LLM providers, dev tools, and host filesystem access.
> **Looking for pi?** As of **v2.0.0** the [pi](https://github.com/earendil-works/pi) coding-agent is no longer bundled here. It now ships as its own self-contained image, **[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)** (also on [Docker Hub](https://hub.docker.com/r/joakimp/pi-devbox)), built on a shared Debian base and able to share this image's mempalace palace. Pull `joakimp/pi-devbox:latest` instead of the old `*-with-pi` / `pi-only` tags. See the [v2.0.0 CHANGELOG](CHANGELOG.md) for the migration details (including the `~/.pi/npm-global` → `~/.config/opencode/npm-global` prefix move).
## Why?
The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based and minimal — no git, no dev tools, broken PTY support due to musl/glibc incompatibility. This project provides a **Debian-based, production-ready** alternative using the current v1.x release.
@@ -25,7 +27,7 @@ $EDITOR .env
docker compose run --rm devbox
```
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `pi`, `omos`, etc.
This pulls `joakimp/opencode-devbox:latest` from Docker Hub and mounts `WORKSPACE_PATH` at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a **login shell** — from there run `opencode` to start the harness, or do `aws sso login` first, launch `omos`, etc. To start opencode directly and skip the shell, pass it as the command: `docker compose run --rm devbox opencode`.
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
@@ -144,8 +146,8 @@ docker compose exec -u developer devbox aws --version
| `EDITOR` | Default text editor | `nvim` |
| `ENABLE_OMOS` | Enable oh-my-opencode-slim multi-agent orchestration | `false` |
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
| `OMOS_SKILLS` | Symlink bundled OMOS skills from the image into `~/.agents/skills/` each start | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start (does not affect skills) | `false` |
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
### Reaching your LAN from the container
@@ -187,11 +189,11 @@ Now `dssh my-nas` routes container → host → LAN peer, pulling HostName/User/
> This ships the **mechanism** only — your specific target hosts are facts about *your* network (and a laptop roams between several), so they live in your own host-side config, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`).
#### Gotcha: per-host `ControlPath` and `pi --ssh`
#### Gotcha: per-host `ControlPath` and read-only `~/.ssh`
The base image bakes a `Host *` default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf`) that points `ControlPath` at the writable, per-container `/tmp/sshcm/` (created mode-700 on every start by `entrypoint-user.sh`). Multiplexing therefore works out of the box. **But your bind-mounted `~/.ssh/config` is read first, and SSH uses the first value it sees** — so any per-host block that sets its own `ControlPath` under `~/.ssh/` (a common CGNAT-multiplexing pattern, e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`) **wins, and then fails inside the container** because `~/.ssh` is mounted **read-only** — the master socket can't bind (`cannot bind … Read-only file system`).
This bites `pi --ssh <host>` especially: the SSH layer fails to establish the master and pi silently falls back to running its `read`/`write`/`edit`/`bash` tools **locally in the container** instead of on the remote (watch for the missing `SSH ⚡` in the status bar — and `hostname` returning the container ID).
This bites any in-container tool that opens an SSH connection to a remote host (git over SSH, `rsync`, remote-execution agents): the master fails to establish and the connection either errors or silently degrades.
**Fix (host-side, one line):** in your host's `~/.ssh/config`, either drop the per-host `ControlPath` (to inherit the writable baked default) or point it at a path that's writable inside the container too:
@@ -201,11 +203,11 @@ Host my-remote
ControlPath /tmp/sshcm/%r@%h:%p # writable on both host and container
```
`/tmp/sshcm/` is also writable on the host (macOS/Linux), so native (non-container) `ssh`/`pi --ssh` from the host keeps working and CGNAT multiplexing is preserved (`ControlMaster`/`ControlPersist` unchanged — only the socket *directory* moves). Note SSH does not create the `ControlPath` parent dir; the container makes `/tmp/sshcm` every start, but on the host run `mkdir -p /tmp/sshcm` once if it doesn't already exist.
`/tmp/sshcm/` is also writable on the host (macOS/Linux), so native (non-container) `ssh` from the host keeps working and CGNAT multiplexing is preserved (`ControlMaster`/`ControlPersist` unchanged — only the socket *directory* moves). Note SSH does not create the `ControlPath` parent dir; the container makes `/tmp/sshcm` every start, but on the host run `mkdir -p /tmp/sshcm` once if it doesn't already exist.
### Custom opencode config
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. All changes to `opencode.jsonc`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation.
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. Changes to `opencode.jsonc` and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation. Auto-deployed skills are *not* stored here — skillset and OMOS skills are symlinked into `~/.agents/skills/` and rebuilt on every start (see [Custom skills](#custom-skills) and [docs/omos-skills.md](docs/omos-skills.md)).
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped.
@@ -230,6 +232,8 @@ When a skillset repo is detected, its skills are symlinked into `~/.agents/skill
> **Warning:** Do not bind-mount a host `~/.agents/skills` directory directly into the container. This conflicts with the symlink-based auto-deploy mechanism and causes broken skill references.
On the OMOS variant, the five skills bundled with oh-my-opencode-slim are also symlinked into `~/.agents/skills/` on each start — **from the image**, so pulling a newer image updates them with no installer run and no config reset. See [docs/omos-skills.md](docs/omos-skills.md).
### Neovim configuration
The image includes neovim 0.12 with `EDITOR=nvim` set by default. To use your own neovim config (and have plugins auto-install via lazy.nvim on first start), mount it from the host:
@@ -420,11 +424,7 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
| `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) |
| `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
| `INSTALL_PI` | `false` | **DEPRECATED (removed in v2.0.0)** — install pi alongside opencode. Use [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) instead. |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a pi-only image (still includes Bun if `INSTALL_OMOS=true`; for a fully stripped pi-only image see the `pi-devbox` repo). **Note: the pi-only path is deprecated and removed in v2.0.0.** |
| `INSTALL_PI` | `false` | Install [pi](https://github.com/earendil-works/pi) as alternative/complementary harness. Both clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (~5 MB) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (~1 MB) into `/opt/`; entrypoint deploys them on container start. ~150 MB total image growth. |
| `PI_VERSION` | `latest` | npm version of `@earendil-works/pi-coding-agent`. Floats by default (image rebuild = pi update). |
| `PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF` | `main` | Git refs for the toolkit/extensions clones. Pin to a tag/commit for reproducibility. |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a base with no harness (still includes Bun if `INSTALL_OMOS=true`). |
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
@@ -459,7 +459,7 @@ ENABLE_OMOS=true
docker compose run --rm devbox
```
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize.
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize. The installer no longer manages skills (`--skills=no`); the bundled skills are symlinked from the image on every start — see [docs/omos-skills.md](docs/omos-skills.md).
### OMOS Environment Variables
@@ -467,8 +467,8 @@ On first start, the entrypoint runs the oh-my-opencode-slim installer in non-int
|---|---|---|
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
| `OMOS_TMUX` | `false` | Enable tmux pane integration (tmux is included in the base image) |
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
| `OMOS_SKILLS` | `true` | Symlink the bundled OMOS skills (`clonedeps`, `codemap`, `deepwork`, `oh-my-opencode-slim`, `simplify`) from the image into `~/.agents/skills/` on each start. Independent of `ENABLE_OMOS`. See [docs/omos-skills.md](docs/omos-skills.md) |
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config). Does **not** affect skills |
### Custom Configuration
@@ -486,92 +486,6 @@ ping all agents
All six agents should respond if your provider authentication is working.
## pi (alternative/complementary harness)
> **⚠ DEPRECATED since v1.17.2 — removed in v2.0.0.** pi support in
> opencode-devbox (the `INSTALL_PI` build arg, the `*-with-pi` /
> `omos-with-pi` / `pi-only` variants, and the `base-pi-only` tag) is
> deprecated. pi now ships as its own self-contained image:
> **[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)** — pull
> that directly. The section below documents the legacy path until removal.
>
> *Migration note for v2.0.0:* the global npm prefix
> (`NPM_CONFIG_PREFIX`) will move off the pi-specific `~/.pi/npm-global`
> path to a neutral opencode path. Globally `npm install -g`'d tools may
> need their volume/PATH refreshed; the v2.0.0 release notes will carry a
> one-time migration shim and details.
[pi](https://github.com/earendil-works/pi) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the mempalace install and palace data — wing/diary entries created by one are visible to the other.
### Setup
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. If you want pi **without** opencode, use the separate, leaner [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image instead (it's built from the same `Dockerfile.variant` with `INSTALL_OPENCODE=false`, published in its own repo so an opencode-devbox tag never ships without opencode). Alternatively, build from source:
### Build
```bash
docker compose build --build-arg INSTALL_PI=true
# Or: pin a pi version
docker compose build --build-arg INSTALL_PI=true --build-arg PI_VERSION=0.73.0
# Or: pi-only image (no opencode, smaller)
docker compose build --build-arg INSTALL_PI=true --build-arg INSTALL_OPENCODE=false
```
### Run
The default `compose run --rm devbox` invocation drops to a login bash so you can choose:
```bash
docker compose run --rm devbox # bash, then `pi` or `opencode` or `aws sso login`
docker compose run --rm devbox pi # launch pi directly
docker compose run --rm devbox opencode
```
For an attached `compose up -d` container, both harnesses are reachable via `compose exec`:
```bash
docker compose exec -u developer devbox pi
docker compose exec -u developer devbox opencode
docker compose exec -u developer devbox bash
```
### What gets installed
- **`pi` CLI** — npm-installed globally at build time. Version pinned by `PI_VERSION`.
- **pi-toolkit** — keybindings.json (mosh/tmux newline fixes), pi-env.zsh (AWS env loader), settings.json template. Cloned to `/opt/pi-toolkit`; deployed to `~/.pi/agent/` on first container start.
- **pi-extensions** — 7 extensions, cloned to `/opt/pi-extensions` and symlinked into `~/.pi/agent/extensions/`:
- `confirm-destructive` — confirm-prompt before dangerous bash commands and session actions.
- `ext-toggle``/ext` slash command to list and enable/disable extensions at runtime (rename-to-disable; survives `/reload`).
- `git-checkpoint` — per-turn `git stash` checkpoint, restorable on `/fork`.
- `mcp-loader` — generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing the tools as native pi tools. Supports both **local stdio** subprocesses (`uvx mcp-searxng`, `gitea-mcp`, …) and **remote streamable-HTTP** servers per MCP spec 2025-03-26 (e.g. `https://mcp.context7.com/mcp`). Adds a `/mcp` slash command for runtime status / toggle (same UX as `/ext`). See [`pi-extensions/AGENTS.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/AGENTS.md) for transport details and the `headers` config for auth tokens.
- `notify` — native terminal notification when the agent finishes.
- `ssh-controlmaster` — transparent SSH remote execution via persistent ControlMaster socket (when pi is launched with `--ssh user@host`).
- `todo``todo` tool for the agent + `/todos` for the user.
- **mempalace bridge** — separate `mempalace.ts` extension symlinked from the cloned `mempalace-toolkit`. Provides pi's MCP tools for palace search/diary/knowledge-graph with bespoke agent-identity injection from `$MEMPALACE_AGENT_NAME`. Coexists with `mcp-loader` rather than replacing it — don't list `mempalace` in settings.json's `mcp` block too, or you'll get duplicate tool registrations.
- **MCP servers (none baked in beyond mempalace)** — the loader registers nothing by default. Add servers by editing `~/.pi/agent/settings.json` and `/reload`. Examples (mcp-searxng for web search, context7 for live library docs) are in the `pi-extensions` README.
### Persistence
`~/.pi/` is mounted on the `devbox-pi-config` named volume. Everything below survives container recreate **and** image rebuilds:
- `~/.pi/agent/settings.json` (provider/model, theme selection, the `mcp` block, and the `packages` array tracking installed pi packages).
- `~/.pi/agent/extensions/` (hand-placed extensions and the symlinks deployed by `pi-extensions/install.sh`).
- `~/.pi/agent/sessions/`, `~/.pi/agent/auth.json`.
- `~/.pi/agent/git/<host>/<path>/` (pi packages installed via `pi install git:...`).
- `~/.pi/npm-global/` (pi packages installed via `pi install npm:...`, plus any `npm install -g` invoked as the `developer` user). `NPM_CONFIG_PREFIX` is pre-set in the image, the prefix's `bin/` is on `PATH`, and the directory itself lives on the volume — so user-installed themes, skills, and extensions survive everything short of `docker compose down -v`.
The **baked** pi binary (and pi-toolkit / pi-extensions repos under `/opt/`) live on the image filesystem, not the volume. Image rebuild is the upgrade path for those — same contract as `OPENCODE_VERSION`. If you `npm install -g @earendil-works/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
### Configuration
The entrypoint copies `pi-toolkit/settings.example.json` to `~/.pi/agent/settings.json` on first start. Edit it to set provider/model:
```bash
docker compose exec -u developer devbox $EDITOR ~/.pi/agent/settings.json
```
The AWS env loader (`pi-env.zsh`) reads `~/.config/pi/.env` if you bind-mount one; otherwise pi uses container env vars passed via `.env`.
## AWS Bedrock Authentication
When using AWS Bedrock as your LLM provider, you need:
+8 -4
View File
@@ -25,8 +25,6 @@ services:
# args:
# INSTALL_GO: "false"
# INSTALL_OMOS: "false"
# INSTALL_PI: "false"
# # PI_VERSION: "latest"
# # INSTALL_OPENCODE: "true"
container_name: opencode-devbox
stdin_open: true
@@ -58,7 +56,13 @@ services:
# the container's skill/instruction symlinks independent from the host,
# allowing both native and containerized opencode on the same machine.
- devbox-opencode-config:/home/developer/.config/opencode
- devbox-pi-config:/home/developer/.pi
# Legacy pi config volume (pi was removed in v2.0.0). Left commented so
# fresh installs carry no dead weight. If you are UPGRADING from a
# pre-v2.0.0 image and had global npm packages installed via
# `npm install -g` (they lived under ~/.pi/npm-global), uncomment this
# for ONE container start: entrypoint-user.sh's migration shim copies
# them to ~/.config/opencode/npm-global, after which you can remove it.
# - 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
@@ -138,7 +142,7 @@ services:
volumes:
devbox-opencode-config:
devbox-pi-config:
# devbox-pi-config: # legacy (pi removed v2.0.0) — uncomment with the mount above only to migrate old global npm packages
devbox-ssh-local:
devbox-data:
devbox-state:
+26
View File
@@ -129,6 +129,31 @@ deprecation:
by `Dockerfile.base` + `Dockerfile.variant` produced by PR-1 of
this work.
### Purge the orphaned `base-pi-only*` Hub tags (manual, post-merge)
Until PR-5, the `build-variant-pi-only` job re-publishes
`joakimp/pi-devbox:base-pi-only` (floating) and a fresh
`base-pi-only-vX.Y.Z` on **every** opencode-devbox release. These tags
are orphaned legacy artifacts: pi-devbox v1.0.0+ builds its own
debian-based `base-<hash>` and **no pi-devbox build input references
`base-pi-only`** (verified 2026-06-10 — only historical mentions remain
in pi-devbox's CHANGELOG/AGENTS/DOCKER_HUB). Nothing consumes them.
Once PR-5 deletes the `build-variant-pi-only` job, the publisher is
gone — so this is the moment to purge the accumulated tags from the
**`joakimp/pi-devbox`** Docker Hub repo (NOT opencode-devbox):
- Delete the floating `base-pi-only` tag.
- Delete every versioned snapshot: `base-pi-only-v1.17.2`,
`base-pi-only-v1.16.2`, `base-pi-only-v1.15.13e`, … (all of them).
Do this **after** PR-5 is merged and the first post-PR-5 release has
built, to confirm no new `base-pi-only*` tag reappears. If you purge
before PR-5, the next opencode-devbox release simply recreates the
floating tag (whack-a-mole). Deletion is via the Hub UI
(`hub.docker.com/r/joakimp/pi-devbox/tags`) or the Hub API
(`DELETE /v2/repositories/joakimp/pi-devbox/tags/<tag>/` with a Hub PAT).
## Two-step deprecation path (recommended)
Rather than a single big-bang removal, use a deprecation cycle:
@@ -194,6 +219,7 @@ After PR-5 lands, the following should be true:
- `grep -ri "INSTALL_PI\|pi-toolkit\|pi-extensions\|pi-fork\|pi-observational-memory\|base-pi-only" .` in opencode-devbox returns no matches.
- `docker history joakimp/opencode-devbox:latest` shows no pi-related layers.
- The opencode-devbox CI matrix builds only `base` and `omos` variants.
- A post-PR-5 release does NOT recreate any `joakimp/pi-devbox:base-pi-only*` tag (publisher confirmed gone), after which those orphaned tags are purged from the pi-devbox Hub repo.
- pi-devbox CI is unaffected (it's a different repo).
- Both repos build cleanly in their own CI without referencing the other.
+6 -10
View File
@@ -29,14 +29,11 @@ cp docs/manual-host-publish.sh /tmp/manual-publish-vX.Y.Z.sh
# Edit at top of file:
# RELEASE_TAG="vX.Y.Z"
# BASE_HASH="<12-char hash from CI's base-decide step>"
# PI_VERSION="<from npm registry, see step 2 below>"
# OMOS_VERSION="<from npm registry, see step 2 below>"
bash /tmp/manual-publish-vX.Y.Z.sh
```
Keep the historical script in `docs/` as-is — it's an archive of the v1.15.12 publish, useful as a reference if a future debug needs to compare exact arg sets across releases. Don't edit it in place.
The sections below explain what the script does and what you need to know to edit those four constants safely.
The sections below explain what the script does and what you need to know to edit those three constants safely.
## 1. Pin RELEASE_TAG
@@ -49,16 +46,15 @@ git describe --tags --exact-match HEAD
The script asserts `HEAD == ${RELEASE_TAG}^{commit}` before doing anything destructive. If you've drifted, fix it with `git checkout` before running.
## 2. Pin PI_VERSION and OMOS_VERSION
## 2. Pin OMOS_VERSION
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads concrete versions through every variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads the concrete version through the omos variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
```bash
curl -sf https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest | jq -r .version
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
```
Paste the two version strings into the script's `PI_VERSION` / `OMOS_VERSION` constants. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
Paste the version string into the script's `OMOS_VERSION` constant. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
## 3. Pin BASE_HASH
@@ -101,8 +97,8 @@ After the constants are set, the script runs a 5-step procedure. No editing need
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
3. **Promote `base-latest`**`docker buildx imagetools create` re-tags by manifest reference. No rebuild.
4. **Variants × 5** — sequential (not parallel; one host's egress can't saturate five multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
5. **Verify** — prints the digest of all 12 expected tags (10 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
4. **Variants × 2** — sequential (not parallel; one host's egress can't saturate multiple multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
5. **Verify** — prints the digest of all 6 expected tags (4 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
+21 -37
View File
@@ -1,35 +1,34 @@
#!/usr/bin/env bash
# Manual publish of opencode-devbox v1.15.12 — bypasses broken Gitea-runner
# Hub push by building & pushing from a developer host (Orbstack/Docker Desktop).
# Manual publish of opencode-devbox — bypasses a broken Gitea-runner Hub push
# by building & pushing from a developer host (Orbstack/Docker Desktop).
#
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
# 2. Promote → joakimp/opencode-devbox:base-latest
# 3. Build & push 5 variants on top of base-<hash>:
# :v1.15.12 :latest (INSTALL_OPENCODE only)
# :v1.15.12-omos :latest-omos (+ OMOS)
# :v1.15.12-with-pi :latest-with-pi (+ pi)
# :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both)
# :v1.15.12-pi-only :latest-pi-only (pi, no opencode)
# 3. Build & push 2 variants on top of base-<hash>:
# :vX.Y.Z :latest (INSTALL_OPENCODE only)
# :vX.Y.Z-omos :latest-omos (+ OMOS)
#
# pi was removed in v2.0.0 — there are no pi variants here anymore.
#
# Usage on your host:
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
# (docker buildx ls should show linux/amd64,linux/arm64).
# 2. docker login docker.io (joakimp account)
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout v1.15.12
# 4. bash /path/to/this/script.sh
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout <RELEASE_TAG>
# 4. Edit RELEASE_TAG / BASE_HASH / OMOS_VERSION below to match the release.
# 5. bash /path/to/this/script.sh
#
# Total expected time: ~25-40 min on a recent Mac (4 multi-arch builds, base
# Total expected time: ~15-25 min on a recent Mac (2 multi-arch builds, base
# layers cache after the first variant).
set -euo pipefail
IMAGE="joakimp/opencode-devbox"
RELEASE_TAG="v1.15.12"
BASE_HASH="8d72a9e44796" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
RELEASE_TAG="v2.0.0" # EDIT per release
BASE_HASH="REPLACE_ME" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
BASE_TAG="base-${BASE_HASH}"
PI_VERSION="0.76.0" # resolved from npm @earendil-works/pi-coding-agent latest (2026-05-28)
OMOS_VERSION="1.1.1" # resolved from npm oh-my-opencode-slim latest (2026-05-28)
OMOS_VERSION="latest" # resolve from npm oh-my-opencode-slim latest, then pin
PLATFORMS="linux/amd64,linux/arm64"
# -------- preflight --------
@@ -65,14 +64,12 @@ fi
echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG}${IMAGE}:base-latest"
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
# -------- 3-5. variants --------
# -------- 3-4. variants --------
build_variant() {
local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" | "-pi-only"
local suffix="$1" # "" | "-omos"
local install_omos="$2"
local install_pi="$3"
local install_opencode="${4:-true}"
local install_opencode="${3:-true}"
local extra_args=()
[[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}")
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
local versioned="${IMAGE}:${RELEASE_TAG}${suffix}"
@@ -85,7 +82,6 @@ build_variant() {
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
--build-arg "INSTALL_OPENCODE=${install_opencode}" \
--build-arg "INSTALL_OMOS=${install_omos}" \
--build-arg "INSTALL_PI=${install_pi}" \
${extra_args[@]+"${extra_args[@]}"} \
-t "${versioned}" \
-t "${floating}" \
@@ -93,29 +89,17 @@ build_variant() {
.
}
echo "==> [3/7] Variant: base (opencode only)"
build_variant "" false false
echo "==> [3/4] Variant: base (opencode only)"
build_variant "" false
echo "==> [4/7] Variant: omos"
build_variant "-omos" true false
echo "==> [5/7] Variant: with-pi"
build_variant "-with-pi" false true
echo "==> [6/7] Variant: omos-with-pi"
build_variant "-omos-with-pi" true true
echo "==> [7/7] Variant: pi-only (pi without opencode)"
build_variant "-pi-only" false true false
echo "==> [4/4] Variant: omos"
build_variant "-omos" true
echo
echo "==> Done. Verifying tags on Hub:"
for t in \
"${RELEASE_TAG}" "latest" \
"${RELEASE_TAG}-omos" "latest-omos" \
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
"${RELEASE_TAG}-pi-only" "latest-pi-only" \
"${BASE_TAG}" "base-latest"
do
d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING")
+110
View File
@@ -0,0 +1,110 @@
# OMOS bundled skills
How the five skills bundled with [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)
(OMOS) are deployed in this image, why the mechanism changed, and how to keep
them up to date.
## TL;DR
- OMOS bundles five skills: `clonedeps`, `codemap`, `deepwork`,
`oh-my-opencode-slim`, `simplify`.
- They are **symlinked from the image** into `~/.agents/skills/` on every
container start by `entrypoint-user.sh` (gated by `OMOS_SKILLS`, default
`true`).
- **To update them, pull a newer image and recreate the container** — no
installer run, no config reset:
```bash
docker compose pull
docker compose up -d --force-recreate
```
## Why it works this way
The skills ship inside the OMOS npm package, baked into the image at
`/usr/lib/node_modules/oh-my-opencode-slim/src/skills/<name>/`. On start the
entrypoint creates one absolute symlink per skill into `~/.agents/skills/`
the same flat directory the [skillset](#relationship-to-skillset) deploy uses
and which opencode scans (directly and via the `~/.claude/skills` pointer).
Because the symlink target lives in the **image**, not in a volume, the skill
content tracks whatever image you run. Pull a newer `*-omos` image and the
skills update on the next container start. `~/.agents/skills/` is itself an
ephemeral container-layer directory rebuilt from scratch on every start, so the
reconcile is idempotent and self-healing.
The symlinks are **absolute** (unlike skillset's relative links): the target is
always inside the container at a fixed `/usr` path, so there is no host/container
path divergence to guard against.
## The old mechanism (and the trap it created)
Previously the OMOS *installer* (`oh-my-opencode-slim install --skills=yes`)
**copied** the skills into `~/.config/opencode/skills/` — but only on the very
first container start, gated by the absence of `oh-my-opencode-slim.json`. That
directory is the persistent `devbox-opencode-config` named volume.
The consequence: once the config existed, the installer was skipped forever, so
the copied skills **froze** at whatever the image shipped on first run. Pulling
a newer image did nothing (the copies lived in the volume, not the image). The
only refresh path was `OMOS_RESET=true`, which runs `install --reset` and
**also overwrites your hand-tuned `opencode.jsonc`** (model choices, agent
config). Updating skills meant clobbering unrelated config — a bad trade.
The installer has no skills-only refresh flag (`--skills=yes|no` is all-or-
nothing within a full install; `--reset` overwrites everything), so the fix was
to stop using the installer for skills entirely. The two `install` invocations
in `entrypoint-user.sh` now pass `--skills=no`; the installer manages only the
agent config (`oh-my-opencode-slim.json`).
## One-time migration of frozen copies
Existing volumes still contain the old frozen real directories under
`~/.config/opencode/skills/`. Because opencode prefers a name found there over
the same name in `~/.agents/skills/`, those stale copies would *shadow* the
fresh image-sourced symlinks. On the first start after upgrading, the entrypoint
**backs each of them up — never deletes** — to
`~/.config/opencode/skills/<name>.bak.<epoch>` and writes a marker at
`~/.config/opencode/.omos-skills-migrated` so the migration runs exactly once.
Only real directories are touched; any symlink in that directory is left alone.
If everything looks right after a few sessions, the backups are safe to remove:
```bash
rm -rf ~/.config/opencode/skills/*.bak.*
```
## Relationship to skillset
The [skillset](https://gitea.jordbo.se/joakimp/skillset) repo deploys its own
version-controlled skills into `~/.agents/skills/` via
`deploy-skills.sh --bootstrap --prune-stale`, which the entrypoint runs on every
start — *before* the OMOS reconcile. If a skill name exists in both, **OMOS
wins**: the reconcile uses `ln -sfn`, which replaces any existing symlink. Today
the only overlap is `simplify` (the OMOS copy is richer — it bundles
`codemap.md` and a `README.md`), so `simplify` was removed from the skillset
repo and the image copy owns the name.
## Configuration
| Variable | Default | Effect |
|---|---|---|
| `OMOS_SKILLS` | `true` | Symlink the bundled skills into `~/.agents/skills/` on each start. Set `false` to deploy no skills from the image. |
`OMOS_SKILLS` is **independent of `ENABLE_OMOS`**: the skills are useful to plain
opencode even when the multi-agent orchestration config is not enabled. The
reconcile is additionally gated on the bundled-skills source being present, so
it is automatically a no-op on the non-OMOS (`base`) image variant.
## Troubleshooting
- **A skill didn't update after `docker compose pull`.** Make sure you
recreated the container (`docker compose up -d --force-recreate`); a plain
restart reuses the old container layer. Confirm the link target —
`readlink ~/.agents/skills/deepwork` should point under
`/usr/lib/node_modules/oh-my-opencode-slim/src/skills/`.
- **A skill disappeared.** If OMOS upstream restructured its package the symlink
target may no longer exist. The build-time smoke test asserts the source path,
so this should be caught in CI; if you hit it at runtime, look for a dangling
link in `ls -l ~/.agents/skills/`.
- **I want a frozen copy back.** It's at
`~/.config/opencode/skills/<name>.bak.<epoch>` until you delete it.
+92 -65
View File
@@ -36,6 +36,32 @@ if [ -d "$SKEL_DIR" ]; then
done
fi
# ── v2.0.0 migration: relocate npm global prefix off the legacy ~/.pi path ──
# Pre-v2.0.0 images set NPM_CONFIG_PREFIX=~/.pi/npm-global (a pi-specific
# path). v2.0.0 removed pi and moved the prefix to
# ~/.config/opencode/npm-global, which is a persistent named volume in every
# compose layout (the old ~/.pi volume was only in docker-compose.yml). If a
# user upgraded with the old ~/.pi volume still mounted, copy their
# previously globally-installed npm packages to the new prefix once so they
# remain on PATH. The marker keeps this idempotent and a no-op for fresh
# installs; the whole block is harmless when the old path is absent.
NEW_NPM_PREFIX="$HOME/.config/opencode/npm-global"
OLD_NPM_PREFIX="$HOME/.pi/npm-global"
MIGRATION_MARKER="$NEW_NPM_PREFIX/.migrated-from-dot-pi"
if [ -d "$OLD_NPM_PREFIX" ] && [ ! -f "$MIGRATION_MARKER" ]; then
echo "Migrating npm global prefix: ~/.pi/npm-global -> ~/.config/opencode/npm-global"
mkdir -p "$NEW_NPM_PREFIX"
# cp -n: never overwrite a file already in the new prefix (a freshly
# installed package wins over the legacy copy).
for sub in lib bin share; do
if [ -d "$OLD_NPM_PREFIX/$sub" ]; then
mkdir -p "$NEW_NPM_PREFIX/$sub"
cp -an "$OLD_NPM_PREFIX/$sub/." "$NEW_NPM_PREFIX/$sub/" 2>/dev/null || true
fi
done
touch "$MIGRATION_MARKER" 2>/dev/null || true
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
@@ -70,62 +96,6 @@ fi
# generated) and no-ops if OPENCODE_PROVIDER is unset.
python3 /usr/local/lib/opencode-devbox/generate-config.py
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
# 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 6
# 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>`. Verified 2026-06-03: 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.
@@ -148,6 +118,66 @@ if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
fi
# ── OMOS bundled skills: symlink from the image into the flat skills dir ──
# The oh-my-opencode-slim package bundles its skills at a fixed, image-internal
# path (npm global prefix /usr — see Dockerfile.variant). Historically the omos
# *installer* COPIED them into ~/.config/opencode/skills/ on first run only,
# freezing them in the persistent `devbox-opencode-config` named volume: pulling
# a newer image never refreshed them, and the only update path was
# `OMOS_RESET=true` (which also clobbers the user's hand-tuned opencode config).
#
# Instead we symlink them from the IMAGE into ~/.agents/skills/ — the same flat
# dir skillset uses, which opencode scans (directly and via the ~/.claude/skills
# pointer). Because the link targets live in the image, `docker compose pull` +
# recreate updates the skills for free: no installer run, no config reset.
#
# ~/.agents/skills/ is authoritative. The legacy ~/.config/opencode/skills/ real
# dir is intentionally bypassed; a one-time migration backs up (never destroys)
# the frozen copies it holds so they stop shadowing the fresh image-sourced
# skills. The migration marker lives in the parent config dir, not inside
# skills/, so it never interferes with that directory's contents.
#
# Absolute symlink (not relative like skillset): the target is always inside the
# container at a fixed /usr path, and ~/.agents/skills/ is an ephemeral
# container-layer dir rebuilt each start — there is no host/container path
# divergence to guard against. The whole block is non-fatal (`{ … } || true`):
# a transient ln/mv failure must never brick container startup, mirroring the
# skillset deploy above. Runs AFTER skillset deploy so OMOS wins any name
# collision (e.g. `simplify`) via `ln -sfn`. Gated by OMOS_SKILLS (default true)
# and the presence of the bundled skills (omos-variant images only).
if [ "${OMOS_SKILLS:-true}" = "true" ]; then
OMOS_SKILLS_SRC=""
for cand in \
/usr/lib/node_modules/oh-my-opencode-slim/src/skills \
/usr/local/lib/node_modules/oh-my-opencode-slim/src/skills; do
if [ -d "$cand" ]; then OMOS_SKILLS_SRC="$cand"; break; fi
done
if [ -n "$OMOS_SKILLS_SRC" ]; then
{
AGENTS_SKILLS_DIR="$HOME/.agents/skills"
OPENCODE_SKILLS_DIR="$HOME/.config/opencode/skills"
OMOS_SKILLS_MARKER="$HOME/.config/opencode/.omos-skills-migrated"
mkdir -p "$AGENTS_SKILLS_DIR"
for skill_path in "$OMOS_SKILLS_SRC"/*/; do
[ -d "$skill_path" ] || continue
name="$(basename "$skill_path")"
# OMOS wins collisions: -f replaces an existing symlink (e.g. skillset's).
ln -sfn "${skill_path%/}" "$AGENTS_SKILLS_DIR/$name"
# One-time unshadow: back up — never destroy — the frozen real copy the
# old installer left in the persistent config volume. `! -L` ensures we
# only ever touch a real dir, never a symlink a user/skillset created.
if [ ! -f "$OMOS_SKILLS_MARKER" ] \
&& [ -d "$OPENCODE_SKILLS_DIR/$name" ] \
&& [ ! -L "$OPENCODE_SKILLS_DIR/$name" ]; then
mv "${OPENCODE_SKILLS_DIR:?}/$name" \
"${OPENCODE_SKILLS_DIR}/${name}.bak.$(date +%s)"
fi
done
touch "$OMOS_SKILLS_MARKER" 2>/dev/null || true
} || true
fi
fi
CONFIG_DIR="$HOME/.config/opencode"
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
@@ -169,15 +199,14 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
OMOS_TMUX_FLAG="yes"
fi
OMOS_SKILLS_FLAG="yes"
if [ "${OMOS_SKILLS:-true}" = "false" ]; then
OMOS_SKILLS_FLAG="no"
fi
# Skills are NOT installer-managed any more — they are symlinked from the
# image into ~/.agents/skills/ by the OMOS bundled-skills block above
# (gated by OMOS_SKILLS). Always pass --skills=no so the installer never
# writes frozen copies into the persistent config volume.
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}"
--skills=no
echo "oh-my-opencode-slim configured successfully."
else
@@ -188,13 +217,11 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
echo "OMOS_RESET=true — regenerating oh-my-opencode-slim config..."
OMOS_TMUX_FLAG="no"
[ "${OMOS_TMUX:-false}" = "true" ] && OMOS_TMUX_FLAG="yes"
OMOS_SKILLS_FLAG="yes"
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}" \
--skills=no \
--reset
fi
fi
-1
View File
@@ -87,7 +87,6 @@ for dir in \
/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
+14 -15
View File
@@ -56,23 +56,24 @@ HUB_TEMPLATE = f"""# opencode-devbox
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
> **Current `:latest` ships opencode `{{{{OPENCODE_VERSION}}}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image opencode, Node.js, AWS CLI, dev tools |
| `latest` / `vX.Y.Z` | Base image opencode `{{{{OPENCODE_VERSION}}}}`, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
All variants support `linux/amd64` and `linux/arm64`.
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo it ships as
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
> image so an "opencode-devbox" tag never lacks opencode.
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
> opencode-devbox. It ships as the dedicated
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
> shares the same mempalace memory layer. See
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
## Quick Start
@@ -87,7 +88,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:**
@@ -107,11 +108,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
## What's Inside
- **[opencode](https://opencode.ai)** primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, ) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[mempalace](https://github.com/MemPalace/mempalace)** persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
## Authentication
@@ -129,8 +129,7 @@ Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the
| Volume | Mount | Survives |
|---|---|---|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable re-downloads in seconds) |
@@ -147,7 +146,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
@@ -155,7 +154,7 @@ MIT. See <{GITEA}/src/branch/main/LICENSE>.
---
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{{{OPENCODE_VERSION}}}}` placeholder is filled by CI at publish time.
"""
+25 -138
View File
@@ -8,7 +8,7 @@
# - Generated opencode.json has the expected shape
# - MCP wrapper works (when mempalace is installed)
#
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
@@ -23,7 +23,7 @@ if [ "${2:-}" = "--variant" ]; then
fi
if [ -z "$IMAGE" ]; then
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2
echo "usage: $0 <image> [--variant base|omos]" >&2
exit 2
fi
@@ -32,16 +32,10 @@ pass() { echo " ✓ $1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Registration assertions (fork/recall installed by the BASE image's
# entrypoint-user.sh via `pi install /opt/<pkg>`) depend on the base, not the
# variant layer built here. validate.yml builds variants FROM the published
# base-latest, which can lag the entrypoint in the current commit (the base
# only rebuilds on a release tag), so a stale base-latest would red the
# push-to-main run with a false negative. These checks are therefore warn-only
# by default; the release pipeline (docker-publish-split.yml) builds the base
# fresh in the same run and sets STRICT_REGISTRATION=1 to enforce them hard.
# The build-time /opt + node_modules checks below stay hard in every path —
# those are produced by the variant layer and must always be correct.
# Registration assertions for fork/recall were removed in v2.0.0 along with
# pi. STRICT_REGISTRATION is retained as an inert env var for backward
# compatibility with any external caller that still sets it; it has no
# effect now that no pi packages are deployed.
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
run() {
@@ -85,9 +79,6 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
if command -v opencode >/dev/null 2>&1; then
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
fi
if command -v pi >/dev/null 2>&1; then
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
fi
printf " %-15s %s\n" "node" "$(node --version)"
printf " %-15s %s\n" "npm" "$(npm --version)"
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
@@ -117,7 +108,7 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
echo
echo "-- Core binaries --"
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
# image is a pi-only build (or a pure base no harness at all).
# image is a pure base with no harness at all.
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
run "opencode" "opencode --version"
else
@@ -171,116 +162,15 @@ elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
fi
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
# entrypoint-user.sh on first start, so we test by running the entry
# point chain (not just `docker run --entrypoint=""`).
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
else
run "pi" "pi --version"
fi
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"
# pi-fork (fork tool) + pi-observational-memory (recall tool): cloned to
# /opt with node_modules baked at build time (a local-path `pi install` does
# NOT npm-install, so deps MUST already be present for the extension to load).
run "pi-fork clone + node_modules" \
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules && echo ok"
run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules && echo ok"
# Run the full entrypoint as developer to verify install.sh deployment.
# Spin up a long-running container so we can `docker exec` into it from
# the host — the `run` helper above invokes commands INSIDE the image
# and has no docker CLI to nest with.
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
# The deploy order is: pi-toolkit (writes keybindings.json) -> pi-extensions
# (symlinks its *.ts) -> settings.json -> mempalace.ts bridge (LAST). Gating
# only on keybindings.json races: it lands when pi-toolkit finishes, before
# pi-extensions has symlinked its *.ts, so the "*.ts >= 4" check below could
# sample mid-deploy under parallel build load (observed v1.16.2 run 370:
# smoke-with-pi saw <4 .ts while omos-with-pi/pi-only saw 8). Wait for the
# LAST-deployed artifact (the mempalace.ts bridge symlink) AND a settled
# extension count so the deploy is fully complete before any assertion runs.
# Up to 45s — pi-bearing variants have more setup work under load.
for _ in $(seq 1 45); do
if docker exec "$CID" sh -c \
'test -L $HOME/.pi/agent/keybindings.json && \
test -L $HOME/.pi/agent/extensions/mempalace.ts && \
[ "$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l)" -ge 4 ]' \
2>/dev/null; then
break
fi
sleep 1
done
exec_test() {
local label="$1"; shift
local out
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
else
fail "$label: $out"
fi
}
# Like exec_test but warn-only unless STRICT_REGISTRATION=1 (see note at top).
exec_test_reg() {
local label="$1"; shift
local out
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
elif [ "$STRICT_REGISTRATION" = "1" ]; then
fail "$label: $out"
else
warn "$label (warn-only — stale base-latest? set STRICT_REGISTRATION=1 to enforce): $out"
fi
}
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
'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>` (records a relative path into settings.json
# packages). That runs slightly after the keybindings marker, so wait for it.
for _ 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_reg "pi-fork registered in settings.json (fork tool)" \
'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
exec_test_reg "pi-observational-memory registered in settings.json (recall tool)" \
'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
docker rm -f "$CID" >/dev/null 2>&1 || true
trap - EXIT
else
echo " - pi not installed (INSTALL_PI=false)"
fi
# bun: only in the omos and omos-with-pi variants
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
# bun: only in the omos variant
if [ "$VARIANT" = "omos" ]; then
run "bun (omos)" "bun --version"
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
# oh-my-opencode-slim is npm-installed globally (not a bun install);
# verify it shows up in the global module list. We must explicitly point
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
# is set to /home/developer/.pi/npm-global so user-installed packages
# is set to /home/developer/.config/opencode/npm-global so user-installed
# packages
# land on the persistent volume — which means a default `npm ls -g`
# queries the user prefix and would miss the baked binaries even though
# they're correctly on PATH at /usr/bin.
@@ -290,6 +180,15 @@ if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
"NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \
"$EXPECTED_OMOS_VERSION"
fi
# OMOS bundled-skills SOURCE must be present at the fixed image path that
# entrypoint-user.sh symlinks into ~/.agents/skills/ on container start. If
# upstream restructures the package (moves src/skills), the runtime symlinks
# would dangle SILENTLY and the skills would just disappear — assert the
# source here so that breakage fails the build loudly instead. We check the
# source (not the runtime symlinks) because smoke tests run with
# --entrypoint="" and never execute entrypoint-user.sh.
run "omos bundled-skills source" \
"for n in clonedeps codemap deepwork oh-my-opencode-slim simplify; do test -d /usr/lib/node_modules/oh-my-opencode-slim/src/skills/\$n || exit 1; done && echo ok"
else
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
fail "bun should NOT be in base image but was found"
@@ -387,17 +286,10 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo " Uncompressed size: ${SIZE_MB} MB"
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
# Thresholds (uncompressed): base 2600 MB, omos 3300 MB.
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
# the variant landed just over 3500 in v1.15.4's smoke.
# with-pi 2700→2900 and omos-with-pi 3700→3900: baking pi-fork +
# pi-observational-memory node_modules into /opt (fork pulls its
# @earendil-works peer deps, ~150 MB) adds to both pi-bearing variants.
# base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script +
# updated entrypoint + routine apt-get upgrade drift), tripping the
# deliberately zero-headroom 2500 ceiling and skipping promote-base-latest.
@@ -406,17 +298,12 @@ echo " Uncompressed size: ${SIZE_MB} MB"
# v1.16.2: all thresholds bumped +150 MB preemptively ahead of the combined
# opencode 1.15.13->1.16.2 (minor) + pi 0.78.1->0.79.0 (minor) bump. Both
# base (2506/2600) and omos (3206/3300) were sitting on ~94 MB headroom and
# a minor opencode bump has tripped them before (v1.15.0 omos, v1.15.4
# omos-with-pi). Restoring ~250 MB headroom avoids a partial-publish +
# letter-suffix recovery cycle. CI's smoke size print + resolved-versions
# table records the actual landed sizes; tighten later if they come in low.
# a minor opencode bump has tripped them before (v1.15.0 omos). Restoring
# ~250 MB headroom avoids a partial-publish + letter-suffix recovery cycle.
# CI's smoke size print + resolved-versions table records the actual landed
# sizes; tighten later if they come in low.
THRESHOLD=2750
[ "$VARIANT" = "omos" ] && THRESHOLD=3450
[ "$VARIANT" = "with-pi" ] && THRESHOLD=3050
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=4050
# pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it
# lands a bit under base. Threshold 2750 leaves the same headroom pattern.
[ "$VARIANT" = "pi-only" ] && THRESHOLD=2850
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else