f51e9f52a1
Optional integration of pi-coding-agent alongside opencode in the same
container. Both harnesses share the mempalace install and palace path —
wing/diary entries are mutually visible.
Build:
--build-arg INSTALL_PI=true # opt-in
--build-arg PI_VERSION=0.73.1 # pin a version (default: latest)
--build-arg INSTALL_OPENCODE=false # build pi-only image
Dockerfile:
• New INSTALL_PI block: npm install -g @mariozechner/pi-coding-agent
+ git-clones pi-toolkit and pi-extensions to /opt/.
• Existing opencode install gated behind new INSTALL_OPENCODE arg
(default true; existing builds unaffected).
• mkdir adds ~/.pi/agent/extensions for the named volume mount root.
• CMD changed from ['opencode'] to ['bash', '-l']. compose run --rm
devbox now drops to a login shell so users pick the harness; pass
'opencode' or 'pi' explicitly to launch directly. compose exec
workflows are unaffected (bypass entrypoint+CMD).
entrypoint.sh:
• Adds ~/.pi to volume ownership loop.
entrypoint-user.sh:
• New 'pi: deploy toolkit + extensions + mempalace bridge' block runs
pi-toolkit/install.sh, pi-extensions/install.sh, settings.json
template bootstrap, then symlinks the mempalace.ts bridge directly.
Order: toolkit before extensions before bridge. mempalace-toolkit's
full install.sh is intentionally NOT called (its install_skill
would race with skillset auto-deploy --prune-stale).
docker-compose.yml:
• New devbox-pi-config named volume mounted at /home/developer/.pi.
Persists user toggles (/ext-disabled extensions) and settings.json
edits across container recreate. Mirrors devbox-opencode-config
pattern from v1.14.33.
scripts/smoke-test.sh:
• New --variant with-pi (threshold 2700 MB) and --variant omos-with-pi
(3400 MB).
• Pi assertions gated on `command -v pi`: version, /opt/pi-toolkit
clone HEAD, /opt/pi-extensions clone HEAD, deployed keybindings
symlink, ≥4 extension symlinks, mempalace.ts bridge symlink,
settings.json bootstrap.
• Pi state assertions use docker exec from the host (not 'run'),
since the container has no docker CLI.
• opencode core test now gated on INSTALL_OPENCODE presence.
scripts/generate-dockerhub-md.py:
• SECTION_RULES adds 'pi (alternative/complementary harness)': drop.
Section stays in README; dropped from DOCKER_HUB.md to keep under
the 25 kB Docker Hub limit.
Docs:
• README adds full 'pi (alternative/complementary harness)' section.
• AGENTS.md codifies pi install contract, deploy ordering, named
volume rationale, and CMD change.
• CHANGELOG.md gets an Unreleased entry.
• .env.example documents new build args.
• docker-compose.yml example args block updated.
Verification (local builds on arm64):
• Default (INSTALL_PI=false): 1871 MB, all assertions pass — no
regression.
• INSTALL_PI=true: 2110 MB (within 2700 threshold), 37 assertions
pass including pi version, all 7 extensions deployed (6 from
pi-extensions + mempalace.ts bridge), settings.json bootstrap.
Not yet:
• CI workflow updates to add -with-pi tag variants. Deferred until
local path stabilizes through user testing.
• pi-devbox separate repo for fully stripped pi-only image. Phase 2.
87 lines
12 KiB
Markdown
87 lines
12 KiB
Markdown
# AGENTS.md
|
|
|
|
## Project overview
|
|
|
|
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Two image variants (base and omos) are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfile, entrypoint scripts, docker-compose, documentation).
|
|
|
|
## File roles
|
|
|
|
- `Dockerfile` — single multi-stage build. Variants are gated by build args: `INSTALL_OMOS` (Bun + multi-agent layer), `INSTALL_OPENCODE` (default true), `INSTALL_PI` (default false), `INSTALL_MEMPALACE` (default true). All GitHub-sourced binaries are pinned with version ARGs.
|
|
- `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`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup.
|
|
- `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.
|
|
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from `README.md` using explicit section rules. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
|
- `DOCKER_HUB.md` — **auto-generated** from README. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
|
|
- `README.md` — authoritative source documentation. Sections are selected/dropped/replaced for DOCKER_HUB.md per `SECTION_RULES` in `scripts/generate-dockerhub-md.py`.
|
|
- `.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.yml` — CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update 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.
|
|
|
|
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile`).
|
|
- **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.
|
|
|
|
CI produces four Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`.
|
|
|
|
When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context.
|
|
|
|
## Critical conventions
|
|
|
|
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
|
|
- **Two docs to keep in sync (automated)** — `README.md` is the source of truth. `DOCKER_HUB.md` is auto-generated by `scripts/generate-dockerhub-md.py`. When adding a new top-level section to README, either add it to `SECTION_RULES` in that script or the `--check` run will fail CI. `.env.example` must still be hand-updated to match Dockerfile/entrypoint behavior.
|
|
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, 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). 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`.
|
|
- **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) and `~/.pi/agent/settings.json` edits across container recreate.
|
|
- **pi install contract** — `INSTALL_PI=true` (default false) opt-in build arg. `pi` is npm-installed globally at build time; the npm prefix is NOT on a named volume, so `pi update` inside the container does not persist across `--rm` containers. Image rebuild is the upgrade path — 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).
|
|
- **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]`).
|
|
- 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 four `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.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.
|
|
|
|
## Testing changes
|
|
|
|
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
|
|
|
|
```bash
|
|
# Base image
|
|
docker compose build
|
|
bash scripts/smoke-test.sh opencode-devbox --variant base
|
|
|
|
# OMOS image
|
|
docker build --build-arg INSTALL_OMOS=true -t opencode-devbox:omos .
|
|
bash scripts/smoke-test.sh opencode-devbox:omos --variant omos
|
|
```
|
|
|
|
For manual/exploratory testing:
|
|
1. `docker compose run --rm devbox bash`
|
|
2. Check specific tools inside: `nvim --version`, `bat --version`, `uv --version`, `mempalace --help`, etc.
|
|
3. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the `.devbox-owner` sentinel behavior.
|
|
4. For `generate-config.py` changes: run standalone with `HOME=/tmp/fake OPENCODE_PROVIDER=anthropic python3 rootfs/usr/local/lib/opencode-devbox/generate-config.py`.
|
|
|
|
## Commit style
|
|
|
|
Imperative mood, first line summarizes the change. Multi-line body explains "why" when non-obvious. Examples from history:
|
|
- `Fix ownership of named volume mount points in entrypoint`
|
|
- `Add uv package manager to base image for on-demand Python support`
|
|
- `Upgrade base image from Debian bookworm to trixie (current stable)`
|