Compare commits

...

10 Commits

Author SHA1 Message Date
Joakim Persson a31ef52b00 release: v2.6.0 — pi-devbox CLI parity + bg-subagents default + opencode 1.17.13 → 1.17.15
Publish Docker Image / resolve-versions (push) Successful in 7s
Validate / docs-check (push) Successful in 18s
Lint workflows / actionlint (push) Successful in 15s
Publish Docker Image / base-decide (push) Successful in 11s
Validate / base-change-warning (push) Successful in 1m1s
Validate / validate-omos (push) Failing after 4m33s
Validate / validate-base (push) Failing after 13m48s
Publish Docker Image / build-base (push) Successful in 41m18s
Publish Docker Image / smoke-base (push) Successful in 5m42s
Publish Docker Image / smoke-omos (push) Successful in 15m3s
Publish Docker Image / build-variant-base (push) Successful in 16m24s
Publish Docker Image / build-variant-omos (push) Successful in 34m35s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 10s
- opencode 1.17.13 → 1.17.15 (OPENCODE_VERSION in Dockerfile.variant)
- promote CHANGELOG Unreleased → v2.6.0 (2026-07-08) with opencode release notes
- (base-hash-advancing changes already staged in prior two commits: CLI tools + bg-subagents ENV)

AGENTS.md tag/variant/skill counts verified unchanged; DOCKER_HUB.md in sync.
2026-07-08 00:20:29 +02:00
Joakim Persson bab78044a1 feat: bake OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true (base ENV)
opencode gates native background subagents behind this experimental flag;
oh-my-opencode-slim V2+ makes background orchestration its default workflow,
so the omos variant was effectively degraded without it. Set as a base-level
ENV (both variants, harmless for plain opencode) and overridable at runtime.

Documented in lockstep: README env table, .env.example, CHANGELOG Unreleased,
and AGENTS.md (with a removal trigger for when opencode promotes it out of
experimental). smoke-test asserts the var is baked into the image env.

Also folds the prior CLI-tools additions into the CHANGELOG Unreleased block.
2026-07-08 00:16:38 +02:00
Joakim Persson 61ec340e40 feat: add yq, pandoc, graphviz, tldr (tealdeer) + dot-watch — CLI parity with pi-devbox
- apt: yq (YAML query), pandoc (doc converter), graphviz (dot rendering)
- tealdeer: tldr command as ~5MB static musl binary (TEALDEER_VERSION arg)
- dot-watch: auto-render .dot to PNG on save (graphviz-only, no imagemagick)
- README: document new tools
- smoke-test: presence assertions for yq, pandoc, dot, tldr, dot-watch

Excludes socat, imagemagick, studio-expose (pi-studio-specific).
2026-07-07 23:43:14 +02:00
pi ee7cfae1ff release: v2.5.0 — external/shared MemPalace + validate.yml base-change fixes
Publish Docker Image / resolve-versions (push) Successful in 4s
Publish Docker Image / base-decide (push) Successful in 8s
Lint workflows / actionlint (push) Successful in 32s
Publish Docker Image / build-base (push) Successful in 30m54s
Publish Docker Image / smoke-omos (push) Successful in 4m49s
Publish Docker Image / smoke-base (push) Successful in 10m37s
Publish Docker Image / build-variant-omos (push) Successful in 19m4s
Publish Docker Image / build-variant-base (push) Successful in 18m42s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Promotes the Unreleased block to v2.5.0. Bundled contents:
- feat: optional shared/external MemPalace — generate-config.py registers a remote
  MCP endpoint when MEMPALACE_REMOTE_URL is set; adds docker-compose.mempalace.yml.
- fix: validate.yml false-red on base-changing commits (skip validate-base/omos via
  base_changed) + rootfs/ base-input detector regex.
opencode stays 1.17.13 (== npm latest). Base rebuilds (rootfs/generate-config.py
changed); mempalace-toolkit clone advances to main HEAD (external-transport bridge).
2026-07-02 14:50:06 +02:00
pi fb6588ab1f fix(validate.yml): rootfs/ base-input detection (anchored-group regex bug)
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 9s
Lint workflows / actionlint (push) Successful in 14s
Validate / validate-omos (push) Successful in 6m48s
Validate / validate-base (push) Successful in 15m5s
The detect step's regex '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$' anchored
the whole alternation with a trailing $, so the rootfs/ branch only matched a
file literally named 'rootfs/' — never real paths like
rootfs/usr/local/lib/opencode-devbox/generate-config.py (which Dockerfile.base
COPYs into the base). Result: rootfs-only base changes set base_changed=false and
validate-base/validate-omos ran against the stale base-latest instead of skipping
(observed live: run 429, my own generate-config.py commit, did not skip).

Fix: compute the match once into $changed with rootfs/ as a PREFIX
('^(Dockerfile\.base$|rootfs/|entrypoint.*\.sh$)') and gate on -n; reuse it for
the file listing so the two greps can't drift. Bug predates the skip feature
(introduced in dba05da); the skip in 703edbe made it load-bearing.

actionlint clean; detect logic simulated across rootfs/Dockerfile.base/entrypoint/
docs-only change-sets.
2026-07-02 13:23:46 +02:00
pi 703edbe4a1 feat: optional shared/external MemPalace + fix validate.yml false-red on base changes
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 6s
Lint workflows / actionlint (push) Successful in 32s
Validate / validate-base (push) Successful in 3m35s
Validate / validate-omos (push) Successful in 4m35s
Added:
- generate-config.py registers mempalace as a remote MCP endpoint when
  MEMPALACE_REMOTE_URL is set (MEMPALACE_REMOTE_TOKEN -> Bearer), else the local
  stdio command as before. Same env contract as pi-devbox's bridge.
- docker-compose.mempalace.yml: optional shared server (mempalace-mcp --transport http),
  loopback-bound by default.
- compose (both) + .env.example(.shared): MEMPALACE_REMOTE_URL/TOKEN + local-vs-external docs.

Fixed:
- validate.yml: skip validate-base/validate-omos on base-changing commits
  (base-change-warning now exports base_changed). Previously a commit that changed
  the base AND tightened smoke-test.sh in lockstep (v2.4.0 nano/micro) hard-failed
  against the stale base-latest until the release rebuilt it. actionlint clean.

README + CHANGELOG (Unreleased).
2026-07-02 13:09:29 +02:00
pi f7e23d236c release: v2.4.0 — nano + micro editors, CI hardening, opencode 1.17.10 → 1.17.13
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 7s
Validate / validate-omos (push) Failing after 4m28s
Validate / validate-base (push) Failing after 5m8s
Publish Docker Image / smoke-base (push) Successful in 3m39s
Publish Docker Image / smoke-omos (push) Successful in 7m6s
Publish Docker Image / resolve-versions (push) Successful in 4s
Lint workflows / actionlint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 21s
Publish Docker Image / build-base (push) Successful in 31m2s
Publish Docker Image / build-variant-base (push) Successful in 13m58s
Publish Docker Image / build-variant-omos (push) Successful in 23m13s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Promotes the Unreleased block to v2.4.0. Bundled contents:
- feat: nano + micro non-modal editors (d9ad634) — Dockerfile.base change → base rebuild
- ci: sh-vs-bash guard + base-latest digest promote, ported from pi-devbox (acb2096)
- feat: global gitignore baked into the image (6639ba5)
- opencode 1.17.10 → 1.17.13 (Dockerfile.variant)

Also registers .gitea/workflows/lint.yml in AGENTS.md's workflow list.
DOCKER_HUB.md unchanged (HUB_TEMPLATE untouched; --check passes).
Pre-flight: opencode-ai@1.17.13 confirmed on npm (== latest).
2026-07-01 23:35:48 +02:00
pi d9ad634d5a feat: ship nano + micro (non-modal editors) alongside nvim
The image shipped only nvim (EDITOR=nvim), a modal vi-style editor. Add
both a classic and a modern non-modal option so users who aren't
comfortable with vi keybindings have a choice:

- nano (apt): ~2.8 MB installed; deps (libc6, libncursesw6, libtinfo6)
  already present via nvim/less/htop/tmux, so no extra packages pulled in.
- micro: ~12 MB single static Go binary from GitHub releases (same pattern
  as bat/eza/zoxide). Desktop-style keys (Ctrl+S/Ctrl+Q), mouse, syntax
  highlighting. ARG MICRO_VERSION pins; defaults to latest.

Combined ~15 MB (well within the smoke size threshold's ~250 MB headroom,
so no threshold bump). EDITOR stays nvim; both editors are opt-in
(export EDITOR=micro | nano). Uses the canonical micro-editor/micro URL
because the old zyedidia/micro org rename makes /releases/latest redirect
to another /latest, defeating the tag-parsing latest-resolution idiom
(independently verified: old org 302s to micro-editor/micro; both arch
tarballs HTTP 200; latest resolves to v2.0.15).

Base-image change, so it lands on the next base-<hash> rebuild. Updates
README (what's-inside tree + EDITOR note), CHANGELOG (Unreleased/Added),
and smoke-test.sh (nano + micro presence checks). Ported from pi-devbox
3a59e15.
2026-07-01 23:20:49 +02:00
pi acb2096406 ci: port pi-devbox CI hardening — bash-default footgun guard + base-latest digest promote
Two CI-only changes ported from pi-devbox (no runtime/image impact),
adapted to opencode-devbox's split-base 2-variant pipeline. Rides the
next release.

C — eliminate the sh-vs-bash footgun class:
- Add `defaults: run: shell: bash` workflow-wide to docker-publish-split.yml
  and validate.yml. Gitea's default step shell is sh/dash, so bash-only
  syntax in a step that omits `shell: bash` fails silently. All pre-existing
  steps are POSIX, so bash runs them unchanged (no behavioural change).
- New .gitea/workflows/lint.yml (push/PR/dispatch): a Gitea-accurate shell
  guard (scripts/check-workflow-shell.sh) + pinned actionlint + shellcheck.
  The guard closes the actionlint blind spot: actionlint models GitHub
  (default shell bash) so it does NOT flag bash syntax in a shell-less step.
  Guard scans ALL .gitea/workflows/*.yml (hence the validate.yml default too).
  Ported from pi-devbox 26384fe/d1db595.

B — promote-base-latest re-points base-latest by digest, not need_build:
  The gate keyed off need_build=='true', assuming need_build==false meant
  base-latest was current. A dry-run dispatch that pre-builds base-<hash>
  falsifies that, leaving base-latest one base behind. Gate now runs on every
  tag release / promote dispatch; the no-op optimization moved into the step
  as a crane digest compare (re-tags only when base-latest != released
  base-<hash>). Ported from pi-devbox b7197e8.

Validated locally: all 3 workflows YAML-parse; shell guard passes real
workflows and correctly fails a synthetic omit-shell+pipefail workflow;
actionlint (pinned 1.7.7) passes with explicit .gitea/workflows/*.yml glob.
2026-07-01 23:00:20 +02:00
pi 6639ba5820 feat: bake global gitignore (core.excludesFile) into image
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 11s
Validate / validate-omos (push) Successful in 4m20s
Validate / validate-base (push) Successful in 14m0s
Seed ~/.gitignore_global from /etc/skel-devbox (seed-if-absent, like
.bash_aliases/.inputrc, so user edits survive recreate) and wire it via
git config --global core.excludesFile, guarded so a user-set excludesFile
is never overridden. Ignores *.bak, *.bak.*, *~, *.orig, *.swp, *.tmp
across all repos without per-repo .gitignore entries.
2026-06-28 11:52:02 +02:00
19 changed files with 737 additions and 37 deletions
+19
View File
@@ -31,6 +31,19 @@ WORKSPACE_PATH=~/projects
# Path to SSH keys on host
SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ───────────────────────────
# By default each container runs its OWN MemPalace (a local stdio server;
# palace stored at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation.
#
# To instead share ONE MemPalace across several containers / harnesses
# (pi + opencode + native), point every container at an external HTTP
# endpoint. When MEMPALACE_REMOTE_URL is set, no local mempalace-mcp is
# spawned and the devbox-palace volume is irrelevant.
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
# MEMPALACE_REMOTE_TOKEN= # optional — sent as: Authorization: Bearer <token>
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop, also Docker Desktop
# on Windows) the container runs in a Linux VM and CANNOT reach the host's
@@ -104,3 +117,9 @@ SSH_KEY_PATH=~/.ssh
# # 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)
# ── Background subagents (opencode experimental flag) ────────────────
# Baked ON in the image (ENV in Dockerfile.base) because OMOS V2+ default
# orchestration depends on opencode's native background subagents. Uncomment
# to opt out (e.g. to force blocking/foreground orchestration).
# OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false
+7
View File
@@ -27,6 +27,13 @@ GIT_USER_EMAIL=your.name@example.com
# If you have per-user keys: SSH_KEY_PATH=~/<signum>/.ssh
# SSH_KEY_PATH=~/.ssh
# ── MemPalace memory ─────────────────────────────────────────────────
# Default: each container runs its own LOCAL palace. To share ONE external
# MemPalace across containers/harnesses, set the URL (no local server is
# spawned when set). MEMPALACE_REMOTE_TOKEN is an optional bearer token.
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
# MEMPALACE_REMOTE_TOKEN=
# ── Locale (defaults to en_US.UTF-8) ────────────────────────────────
# LANG=sv_SE.UTF-8
# LANGUAGE=sv_SE:sv
+46 -11
View File
@@ -34,6 +34,17 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects
# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the
# default to bash workflow-wide eliminates the whole class of "forgot
# `shell: bash` on this step" bugs. (Ported from pi-devbox, where this class
# bit twice: ed49b8d resolve-versions, b7197e8/b33e9dc promote-base-latest,
# run 418.) All existing dash steps use only POSIX syntax, so bash (a
# superset) runs them unchanged. Enforced by lint.yml's shell guard.
defaults:
run:
shell: bash
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
@@ -519,11 +530,16 @@ jobs:
- base-decide
- build-variant-base
- build-variant-omos
# 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.
# Manual workflow_dispatch with promote_latest=true overrides this
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
# Run on every tag release (and promote_latest=true dispatch). The gate
# deliberately does NOT key off need_build anymore: the no-op optimization
# for genuine cache-hit releases moved INTO the step as a crane digest
# compare (see below). Keying the gate on need_build was wrong because a
# prior dry-run dispatch (promote_latest=false) can pre-build+push
# base-<hash>, making need_build=false on the subsequent tag run even
# though base-latest is still stale — the old gate then skipped promotion
# and left base-latest pointing at the PREVIOUS base. (Ported from
# pi-devbox b7197e8, which hit exactly this on its v1.2.3 dry-run-first
# release, 2026-06-27.)
#
# `always()` wrapper + explicit base-variant success check protects
# against the gitea-Actions default of "skipped need => skip dependent":
@@ -532,8 +548,7 @@ jobs:
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(inputs.promote_latest == 'true' ||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -558,11 +573,31 @@ jobs:
crane auth login docker.io \
-u ${{ vars.DOCKERHUB_USERNAME }} \
-p "${{ secrets.DOCKERHUB_TOKEN }}"
- name: Re-tag base-<hash> as base-latest
- name: Re-tag base-<hash> as base-latest (only if stale)
env:
BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest
run: |
crane copy \
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
${{ env.IMAGE }}:base-latest
set -euo pipefail
# Correctness invariant: after a release, base-latest must resolve to
# the SAME digest as the base-<hash> the just-built variants were
# FROM. Compare digests rather than trusting need_build — a prior
# dry-run dispatch can pre-build base-<hash>, so need_build=false on
# the tag run does NOT imply base-latest is already current. When the
# digests already match (genuine cache-hit release) this is a no-op,
# so we skip the crane copy entirely — preserving the original
# "don't do a tautological retag" intent and avoiding any cosmetic
# transient-failure exposure on releases that change nothing.
want=$(crane digest "${BASE_HASH_REF}")
have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "")
echo "base-<hash> digest: ${want}"
echo "base-latest digest: ${have:-<absent>}"
if [ "${want}" = "${have}" ]; then
echo "base-latest already current; nothing to promote."
else
echo "Promoting base-latest -> ${BASE_HASH_REF}"
crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}"
fi
# ── Phase 6: update Hub description (only on real release runs) ────
update-description:
+68
View File
@@ -0,0 +1,68 @@
name: Lint workflows
# Durable guard against CI-workflow bugs — most importantly the "bash-only
# syntax under the default `sh`/dash shell" footgun. Ported from pi-devbox,
# where this class broke resolve-versions (ed49b8d) and promote-base-latest
# (b7197e8 → run 418). actionlint runs shellcheck against each `run:` step
# using its *effective* shell, so `set -o pipefail` under dash is flagged as
# SC3040 before any expensive build runs. This is cheap (~10s) and independent
# of the build pipeline, so it fires on every push/PR — not just on release
# tags, which is where docker-publish-split.yml is otherwise only triggered.
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
actionlint:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
apt-get update
apt-get install -y --no-install-recommends shellcheck python3-yaml
- name: Gitea shell guard (catches the actionlint blind spot)
# actionlint models GitHub Actions, where the default run shell is
# bash, so it does NOT flag bash syntax in a step that merely OMITS
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
# Gitea (default sh/dash). This guard enforces that every run: step
# resolves to bash under Gitea's real defaults. Run it BEFORE
# actionlint so the more precise diagnostic surfaces first.
run: bash scripts/check-workflow-shell.sh .gitea/workflows
- name: Install actionlint (pinned)
env:
ACTIONLINT_VERSION: 1.7.7
run: |
curl -fsSL \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin actionlint
actionlint --version
- name: Run actionlint
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
# so the guard stays focused on correctness bugs — crucially the
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
# other SC3xxx code.
env:
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
# Pass explicit paths: actionlint's no-arg mode auto-detects a
# project by looking for `.github/workflows`, which doesn't exist in
# this `.gitea/workflows` repo and hard-fails with exit 3
# ("no project was found"). Globbing the workflow files is the
# supported way to lint a non-GitHub layout.
run: actionlint -color .gitea/workflows/*.yml
+38 -11
View File
@@ -35,6 +35,14 @@ on:
branches:
- main
# Gitea Actions' default step shell is `sh` (dash); force bash workflow-wide so
# no run: step silently falls through to dash. Enforced by lint.yml's
# scripts/check-workflow-shell.sh guard, which scans ALL .gitea/workflows/*.yml
# (so this file must resolve to bash too, not just docker-publish-split.yml).
defaults:
run:
shell: bash
jobs:
docs-check:
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
@@ -52,13 +60,19 @@ jobs:
base-change-warning:
# Surfaces a warning when this commit changes base-image inputs
# (Dockerfile.base, rootfs/, entrypoint*.sh). validate.yml uses
# Hub's base-latest as the parent for variant builds, so changes to
# those files are NOT exercised here — only release tags rebuild the
# base via docker-publish-split.yml.
# (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so
# validate-base/validate-omos can skip. validate.yml uses Hub's base-latest
# as the parent for variant builds, so a changed base is NOT exercised here
# — worse, if the same commit tightens smoke-test.sh in lockstep with the
# base change (as the nano/micro commit did), those jobs HARD-FAIL against
# the stale base-latest: a false red that self-heals only once the release
# rebuilds base-latest. So we skip them here and defer full base validation
# to the release path (docker-publish-split.yml on tag push).
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
base_changed: ${{ steps.detect.outputs.base_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -66,18 +80,29 @@ jobs:
fetch-depth: 2
- name: Detect base-input changes
id: detect
run: |
set -e
if ! git diff --name-only HEAD~1 HEAD 2>/dev/null \
| grep -qE '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'; then
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
exit 0
fi
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
# Base inputs baked into Dockerfile.base: Dockerfile.base itself, any
# file under rootfs/ (COPYed into the base), and entrypoint*.sh. NB:
# rootfs/ is a PREFIX match — the earlier '^(...|rootfs/|...)$' anchored
# the whole group, so the rootfs/ branch only matched a file literally
# named 'rootfs/' and never real paths like rootfs/usr/.../x.py.
base_re='^(Dockerfile\.base$|rootfs/|entrypoint.*\.sh$)'
changed="$(git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -E "$base_re" || true)"
if [ -n "$changed" ]; then
echo "base_changed=true" >> "$GITHUB_OUTPUT"
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow — validate-base/validate-omos are SKIPPED to avoid a false failure against the stale base-latest. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
echo "Changed base-input files:"
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
printf '%s\n' "$changed"
else
echo "base_changed=false" >> "$GITHUB_OUTPUT"
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
fi
validate-base:
needs: [base-change-warning]
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -139,6 +164,8 @@ jobs:
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
validate-omos:
needs: [base-change-warning]
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
+2
View File
@@ -29,6 +29,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `.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 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
- `.gitea/workflows/lint.yml` — cheap (~10s) workflow-lint on every push/PR/dispatch: a Gitea-accurate shell guard (`scripts/check-workflow-shell.sh`) plus pinned actionlint + shellcheck. The guard asserts every `run:` step resolves to `bash` under Gitea's `sh` default, closing the actionlint blind spot (actionlint models GitHub, whose default `run` shell is bash). Ported from pi-devbox.
## Versioning scheme
@@ -107,6 +108,7 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 |
- **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`.
- **Background subagents flag baked ON — experimental, watch for promotion** — `Dockerfile.base` sets `ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`. opencode gates native background subagents behind this flag (`packages/opencode/src/tool/task.ts` fails with `Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` when unset); `oh-my-opencode-slim` V2+ makes background orchestration its **default** workflow, so the omos variant is effectively degraded without it. It's a base ENV (applies to both variants; harmless on plain opencode — only *enables* a capability) and stays runtime-overridable (`-e …=false`). It's counted in the base hash, so editing that line advances `base-latest`. **REMOVAL TRIGGER:** when opencode promotes background subagents out of `EXPERIMENTAL_` (flag renamed or made default), drop the ENV. No upstream roadmap date as of opencode 1.17.15 / omos 2.1.0 (2026-07). Documented in lockstep in README env table, `.env.example`, and asserted by `scripts/smoke-test.sh` (`bg-subagents env baked`).
- **MemPalace `diary_write` anyOf workaround — upstream watch target** — `Dockerfile.base` carries a perl RUN block that strips a root-level `anyOf` from `mempalace_diary_write`'s advertised `inputSchema`. Mempalace 3.3.x/3.4.0 advertise `anyOf: [{required:[entry]},{required:[content]}]`, which Anthropic's tools API (and Codex) reject at session start (`input_schema does not support oneOf, allOf, or anyOf at the top level`), making the whole MCP server fail to load. The workaround is idempotent and self-deactivating: when upstream ships the real fix the regex stops matching and the build prints `WARN: ... upstream may have changed shape` — **that WARN is the signal to delete the RUN block.** Upstream status (last checked **2026-06-14**): issue **#1728 is still OPEN**; PR **#1735 is CLOSED UNMERGED (2026-06-11) — do NOT watch it, it is dead**; PR **#1717 is the current live fix candidate**; mempalace PyPI latest is **3.4.0 (== our pin)**, so **no release contains the fix yet** and the workaround must stay. **Removal trigger:** a mempalace release **> 3.4.0** that actually strips the root-level `anyOf` lands on PyPI — then bump `MEMPALACE_VERSION` (in lockstep with pi-devbox) and drop the RUN block. NOTE: `MEMPALACE_VERSION` (the pip pin) and `MEMPALACE_TOOLKIT_REF` (the git ref for the `mempalace-toolkit` clone) are unrelated despite the shared prefix; do not conflate them.
- **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.
+171
View File
@@ -6,6 +6,177 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image
---
## v2.6.0 — 2026-07-08
Minor release. Headline: **CLI-toolset parity with the sibling `pi-devbox`
image** (adds `yq`, `pandoc`, `graphviz`, `tealdeer`/`tldr`, and the `dot-watch`
wrapper) plus **background subagents enabled by default**
(`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`), and an opencode bump
`1.17.13 → 1.17.15`. Touches `Dockerfile.base` (apt list, new binary, new ENV)
so the base image rebuilds (`base-<hash>` advances); the opencode bump rebuilds
the variant layers.
### Added
- **CLI tooling parity with pi-devbox.** Added `yq` (YAML query/transform,
pairs with `jq`), `pandoc` (Markdown↔HTML/etc. document converter), and
`graphviz` (`dot` diagram rendering) via apt; `tealdeer` (the `tldr` command —
quick command examples) as a ~5 MB static musl binary via the floated
`TEALDEER_VERSION` arg; and the `dot-watch` wrapper (`rootfs/usr/local/bin/dot-watch`,
auto-renders a `.dot` file to PNG on save, graphviz-only). Deliberately did
**not** port `socat`, `imagemagick`, or the pi-studio-specific `studio-expose`
bridge. smoke-test asserts presence of all five.
- **Background subagents enabled by default (`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`).**
Baked as a base-level `ENV` in `Dockerfile.base`. opencode gates native
background subagents behind this experimental flag; OMOS V2+ makes background
orchestration its default workflow, so the omos variant was effectively
degraded without it. Overridable at runtime (`-e OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false`).
Documented in README env table and `.env.example`; smoke-test asserts the var
is present in the image environment. Marked experimental upstream — carries a
removal-trigger note in `Dockerfile.base` + AGENTS.md for when opencode
promotes it out of experimental (no upstream roadmap date as of opencode
1.17.15 / omos 2.1.0).
### Changed
- **opencode `1.17.13``1.17.15`.** v1.17.14 adds a code-mode MCP adapter for
confined orchestration scripts, hides the `execute` tool unless code mode is
enabled, and **fixes paginated MCP tool catalogs losing tool metadata +
output-schema validation** (relevant — this image wires the mempalace,
gitea-mcp, and context7 MCP servers). v1.17.15 is bugfixes + desktop/TUI UX
(Z.ai context-overflow error classification, macOS Sequoia titlebar, model
picker). No breaking changes. Release notes:
<https://github.com/anomalyco/opencode/releases>.
---
## v2.5.0 — 2026-07-02
Minor release. Headline: **optional shared/external MemPalace**
`generate-config.py` can now register mempalace as a `remote` MCP endpoint
(`MEMPALACE_REMOTE_URL`, optional `MEMPALACE_REMOTE_TOKEN` → Bearer) so one
palace can serve pi + opencode + native, instead of a per-container local
server; adds `docker-compose.mempalace.yml` for the shared server. Also fixes a
`validate.yml` false-red on base-changing commits (skips variant validation and
corrects the `rootfs/` base-input detector regex). opencode stays `1.17.13`; the
base image rebuilds (`rootfs/…/generate-config.py` changed), so the new config
generator lands in `base-<hash>`.
### Added
- **Optional shared/external MemPalace via `MEMPALACE_REMOTE_URL`.** MemPalace
can now be pointed at one shared HTTP endpoint instead of each container
running its own local server. Set `MEMPALACE_REMOTE_URL=http://<host>:8765/mcp`
(optionally `MEMPALACE_REMOTE_TOKEN`, sent as `Authorization: Bearer`) in
`.env`; `generate-config.py` then registers the `mempalace` MCP server as a
`remote` endpoint instead of the local `mempalace-mcp` stdio command (no local
server is spawned, and the `devbox-palace` volume becomes irrelevant). Leaving
it unset keeps the default local-per-container palace. Mirrors the same env
contract as pi-devbox's `mempalace.ts` bridge, so one palace can serve pi +
opencode + native.
- **`docker-compose.mempalace.yml`** — an optional shared MemPalace server
(`mempalace-mcp --transport http`) that several containers/harnesses can point
`MEMPALACE_REMOTE_URL` at. Binds to `127.0.0.1:8765` by default (the HTTP
transport is unauthenticated — keep it on a trusted network or behind a proxy).
### Fixed
- **`validate.yml` no longer reports a false failure on base-changing commits.**
`validate-base`/`validate-omos` build variants from the published
`base-latest`; when a commit changed base inputs *and* tightened
`smoke-test.sh` in lockstep (as v2.4.0's nano/micro did), those jobs
hard-failed against the still-stale `base-latest` until the release rebuilt it.
The `base-change-warning` job now exports a `base_changed` output and those two
jobs **skip** when base inputs changed, deferring full base validation to the
release path (`docker-publish-split.yml`). The scary red becomes a neutral skip
plus the existing warning. Also fixes the base-input detector itself: its
`^(…|rootfs/|…)$` regex anchored the whole alternation, so the `rootfs/` branch
only matched a file literally named `rootfs/` and never real paths like
`rootfs/usr/…/generate-config.py``rootfs/` is now a prefix match, so
rootfs-only base changes are detected too.
---
## v2.4.0 — 2026-07-01
Minor release. Adds two **non-modal editors** (`nano` + `micro`) alongside
nvim, ports **CI hardening** from pi-devbox (the sh-vs-bash workflow guard and
the `base-latest` digest-based promote fix), bakes a **global gitignore** into
the image, and bumps **opencode `1.17.10` → `1.17.13`**. Because
`Dockerfile.base` changed (nano + micro), this release rebuilds the base image.
### Changed
- **opencode `1.17.10``1.17.13`** (three upstream patches). Highlights:
session snapshots + revert controls (1.17.11); MCP OAuth reconnect/refresh
fixes, a TUI yolo auto-approve mode, and better default small models
(1.17.12); forced reasoning mode for OpenAI-compatible reasoning models plus
a GitHub Copilot stale-response-ID fix (1.17.13). Full notes:
<https://github.com/anomalyco/opencode/releases>.
### Added
- **Global gitignore baked into the image.** A `~/.gitignore_global`
(`*.bak`, `*.bak.*`, `*~`, `*.orig`, `*.swp`, `*.tmp`) is seeded into the home
dir from `/etc/skel-devbox/` on first boot (seed-if-absent, like
`.bash_aliases`/`.inputrc`, so user edits survive recreate) and wired via
`git config --global core.excludesFile`. Personal/tooling backup artifacts are
now ignored across all repos in the container without per-repo `.gitignore`
entries. The `core.excludesFile` wiring is skipped if the user already set one.
- **Non-modal editors `nano` + `micro` alongside `nvim`.** The image shipped
only nvim (`EDITOR=nvim`), a modal vi-style editor. Added both a classic and
a modern non-modal option for users who don't want vi keybindings:
- **nano** (apt): ~2.8 MB installed; its deps (libc6, libncursesw6,
libtinfo6) are already present via nvim/less/htop/tmux, so no extra
packages are pulled in.
- **micro**: ~12 MB single static Go binary from GitHub releases (same
pattern as bat/eza/zoxide). Desktop-style keys (Ctrl+S/Ctrl+Q), mouse,
syntax highlighting. `ARG MICRO_VERSION` pins; defaults to latest.
Combined ~15 MB (<0.5% of the image). `EDITOR` stays `nvim`; both are opt-in
(`export EDITOR=micro | nano`). Uses the canonical `micro-editor/micro` URL
because the old `zyedidia/micro` org rename makes `/releases/latest` redirect
to another `/latest`, defeating the tag-parsing latest-resolution idiom.
Base-image change, so it lands on the next `base-<hash>` rebuild. Ported from
pi-devbox 3a59e15.
- **Workflow-lint guard (`.gitea/workflows/lint.yml` + `scripts/check-workflow-shell.sh`).**
New cheap (~10s) lint workflow that runs on every push/PR (not just release
tags): a Gitea-accurate shell guard plus pinned `actionlint` + `shellcheck`.
The custom guard asserts every `run:` step in every `.gitea/workflows/*.yml`
resolves to an effective shell of `bash`, closing the actionlint blind spot
(actionlint models GitHub, whose default `run` shell is bash, so it does not
flag bash syntax in a step that merely omits `shell:` — the exact way the
sh-vs-bash footgun manifests on Gitea, whose default is `sh`/dash). Ported
from pi-devbox.
### Changed (CI)
- **Workflow-wide `defaults: run: shell: bash`** added to
`docker-publish-split.yml` and `validate.yml`. Gitea Actions' default step
shell is `sh` (dash), so bash-only syntax (`set -o pipefail`, `[[ ]]`,
arrays) in a step that forgets `shell: bash` fails silently. Setting the
default workflow-wide eliminates the whole class. All pre-existing steps use
only POSIX syntax, so bash (a superset) runs them unchanged — no behavioural
change. Preventive port from pi-devbox, where this class bit twice.
### Fixed (CI)
- **`promote-base-latest` re-points `base-latest` by digest, not `need_build`.**
The job gate keyed off `need_build == 'true'`, assuming `need_build == false`
meant `base-latest` was already current. A dry-run dispatch
(`promote_latest=false`) that pre-builds `base-<hash>` falsifies that: the
later tag run sees `need_build == false`, skips promotion, and leaves
`base-latest` one base behind. The gate now runs on every tag release /
promote dispatch, and the no-op optimization moved into the step as a `crane
digest` compare — it re-tags only when `base-latest` actually differs from the
released `base-<hash>` (genuine cache-hit releases stay a no-op). Workflow-only
change; base hash unaffected (no base rebuild). Ported from pi-devbox b7197e8
(which hit this on its v1.2.3 release, 2026-06-27).
---
## v2.3.0 — 2026-06-25
Minor release. Adds an **image-baked fallback skills + harness-instruction**
+66
View File
@@ -35,6 +35,11 @@ ENV DEBIAN_FRONTEND=noninteractive
# apt-get upgrade picks up any security/CVE fixes published between
# debian:trixie-slim base-image rebuilds. Paired with the index update
# and the install in the same layer so we don't bloat image history.
# `nano` is included as a small, non-modal terminal editor for users who
# don't want vi-style modal editing — a companion to nvim and the `micro`
# binary installed further down. ~2.8 MB; its deps (libc6, libncursesw6,
# libtinfo6) are already pulled in by nvim/less/htop/tmux, so it adds no
# extra packages. EDITOR stays nvim; opt in via `export EDITOR=nano`.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
@@ -45,6 +50,7 @@ RUN apt-get update && \
openssh-client \
gnupg \
jq \
yq \
ripgrep \
fd-find \
tree \
@@ -66,6 +72,9 @@ RUN apt-get update && \
rsync \
python3-pip \
python3-venv \
nano \
pandoc \
graphviz \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -204,6 +213,33 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
nvim --version | head -1
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
# who aren't comfortable with vi-style modal editing have a friendly option:
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
# undo), mouse support, and syntax highlighting out of the box. A single
# static Go binary (~12 MB) installed from GitHub releases, exactly like
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
# `export EDITOR=micro` or `git config --global core.editor micro`.
#
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
# (the org rename), so the tag-parsing idiom below would resolve "latest"
# instead of a version. Use the canonical micro-editor/micro URL.
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
# linux-arm64. The tarball extracts to micro-<version>/micro.
ARG MICRO_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
V="${MICRO_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing micro ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
rm -rf /tmp/micro-${V} && \
micro --version
# bat — syntax-highlighted cat replacement
ARG BAT_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
@@ -262,6 +298,22 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
rm -rf /tmp/uv-* && \
uv --version
# tealdeer — Rust port of tldr (community-maintained command examples)
# Provides the `tldr` command; ~5 MB static binary, ~135 MB smaller than
# the Node tldr global. Same UX as the Node version.
ARG TEALDEER_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TEALDEER_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing tealdeer ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
chmod +x /usr/local/bin/tldr && \
tldr --version
# ── MemPalace — local-first AI memory system ─────────────────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Always installed in the base (variant-independent). Set
@@ -351,6 +403,17 @@ ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# Enable opencode's native background subagents. opencode gates this behind an
# experimental flag (packages/opencode/src/tool/task.ts errors with
# "Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"
# when unset). oh-my-opencode-slim V2+ makes background orchestration its DEFAULT
# workflow, so the omos variant is effectively broken without this. Baked here as
# a base ENV (applies to both variants; harmless for plain opencode — it only
# *enables* a capability). Overridable at runtime: -e OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false.
# REMOVAL TRIGGER: when opencode promotes background subagents out of experimental
# (flag becomes default / renamed), drop this ENV. No upstream roadmap date as of
# opencode 1.17.15 / omos 2.1.0 (2026-07).
ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ──
ARG NODE_VERSION=22
@@ -429,9 +492,11 @@ ENV PATH="/home/${USER_NAME}/.config/opencode/npm-global/bin:${PATH}"
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
# Image-baked skills + harness instruction. Under /usr/local so a named volume
# over a home dir (e.g. devbox-opencode-config on ~/.config/opencode) can't
# shadow them; entrypoint-user.sh links them into ~/.agents/skills/ and
@@ -441,6 +506,7 @@ COPY rootfs/usr/local/share/opencode-devbox/ /usr/local/share/opencode-devbox/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/bin/dot-watch \
/usr/local/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
+1 -1
View File
@@ -39,7 +39,7 @@ 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.10
ARG OPENCODE_VERSION=1.17.15
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
+34 -2
View File
@@ -148,6 +148,7 @@ docker compose exec -u developer devbox aws --version
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `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` |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | Enable opencode's native background subagents. Baked on in the image because OMOS V2+ default orchestration depends on it. Set `false` to opt out. opencode marks this **experimental** — see [AGENTS.md](AGENTS.md) removal trigger | `true` |
| `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
@@ -263,6 +264,11 @@ volumes:
- ~/.config/nvim:/home/developer/.config/nvim:ro
```
> **Not a vi person?** The image also ships two non-modal editors alongside nvim:
> **nano** (classic, minimal) and **micro** (modern — desktop-style `Ctrl+S`/`Ctrl+Q`
> keys, mouse, syntax highlighting). `EDITOR` stays `nvim`; opt in per-shell with
> `export EDITOR=nano` (or `micro`), or for git with `git config --global core.editor micro`.
### Python development with uv
The image includes Python 3.13 (from Debian Trixie) and [uv](https://docs.astral.sh/uv/), a fast Python package manager that replaces pip, venv, and pyenv:
@@ -589,6 +595,28 @@ Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/openc
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
### Sharing one palace across containers (external server)
By default each container runs its own local `mempalace-mcp`. To share **one**
palace across several containers/harnesses (pi + opencode + native), run the
bundled server and point clients at it:
```bash
docker compose -f docker-compose.mempalace.yml up -d # serves mempalace-mcp over HTTP
```
Then set in each client's `.env`:
```bash
MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
# MEMPALACE_REMOTE_TOKEN=... # optional; sent as Authorization: Bearer
```
`generate-config.py` then registers mempalace as a `remote` MCP endpoint instead
of the local stdio command — no local `mempalace-mcp` is spawned and the
`devbox-palace` volume is irrelevant. ⚠ The HTTP transport is **unauthenticated**;
keep it on a trusted network or behind a reverse proxy that enforces the token.
### Basic usage
```bash
@@ -807,8 +835,12 @@ Container (Debian trixie)
├── opencode binary
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
├── AWS CLI v2 (SSO + Bedrock auth)
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, curl, tree
├── neovim 0.12, nano, micro, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, yq, curl, tree
├── pandoc (Markdown↔HTML/PDF/etc. conversion)
├── graphviz (dot diagram rendering)
├── tldr (tealdeer — quick command examples)
├── dot-watch (auto-render .dot to PNG on save)
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
├── entrypoint.sh (UID adjustment, git config, provider setup)
+84
View File
@@ -0,0 +1,84 @@
# Shared MemPalace server (optional) — one palace for many clients.
#
# Runs `mempalace-mcp` over HTTP so several containers/harnesses (pi +
# opencode + native) can share ONE palace instead of each keeping its own.
# Point every client at it by setting, in that client's .env:
#
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
#
# (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp.
#
# Start: docker compose -f docker-compose.mempalace.yml up -d
# Stop: docker compose -f docker-compose.mempalace.yml down
# Logs: docker compose -f docker-compose.mempalace.yml logs -f
#
# Why reuse the devbox image? mempalace-mcp is already installed in it, and
# reusing it GUARANTEES the server's mempalace version matches the clients'
# (both are pinned by the same image build). Override with a slimmer image via
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
#
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
# Do NOT expose port 8765 to an untrusted network. The default below binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
# attach them to the shared `mempalace-net` network (container-to-container, no
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
name: mempalace-server
services:
mempalace:
image: ${MEMPALACE_SERVER_IMAGE:-joakimp/opencode-devbox:latest}
container_name: mempalace-server
# Bypass the devbox entrypoint (dev-shell/LAN/config setup) and run the
# HTTP MCP server directly. HOME + explicit --palace pin the data path so
# it does not depend on the image's default user/HOME. Runs as root so it
# can initialise the fresh named volume; the volume is dedicated to this
# server (clients reach it over HTTP, never by mounting it).
entrypoint: []
user: "0:0"
environment:
- HOME=/data
command:
- mempalace-mcp
- --transport
- http
- --host
- "0.0.0.0"
- --port
- "8765"
- --palace
- /data/.mempalace
restart: unless-stopped
# Loopback-only by default (see SECURITY note). Use "8765:8765" to expose on
# all host interfaces, or drop `ports:` entirely and rely on mempalace-net.
ports:
- "127.0.0.1:8765:8765"
volumes:
# The shared palace data — precious; back this up.
- mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma
networks:
- mempalace-net
healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is
# always present — mempalace itself is a python tool in the image).
test:
- CMD
- python3
- -c
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
mempalace-shared:
mempalace-shared-chroma:
networks:
mempalace-net:
name: mempalace-net
+7 -1
View File
@@ -38,6 +38,10 @@ services:
- TERM=xterm-256color
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
# MemPalace transport: empty = LOCAL per-container palace (default);
# set MEMPALACE_REMOTE_URL to share one external palace. See .env.example.
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
volumes:
# Host workspace — user's project directory
- ${WORKSPACE_PATH:-~/src}:/workspace
@@ -73,7 +77,9 @@ services:
# Persist uv data (Python installs)
- devbox-uv:/home/developer/.local/share/uv
# Optional: persist MemPalace data (conversation memory, knowledge graph)
# Optional: persist MemPalace data (conversation memory, knowledge graph).
# LOCAL palace only (default). In EXTERNAL mode (MEMPALACE_REMOTE_URL set)
# the shared server owns the data, so this volume is irrelevant.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB)
+10 -1
View File
@@ -36,6 +36,12 @@ services:
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
# MemPalace transport: leave empty for a LOCAL per-container palace
# (default). Set MEMPALACE_REMOTE_URL to share ONE external MemPalace
# across containers/harnesses; MEMPALACE_REMOTE_TOKEN is an optional
# bearer token. See .env.example.
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
volumes:
# Host workspace — mount your project here
- ${WORKSPACE_PATH:-.}:/workspace
@@ -128,7 +134,10 @@ services:
- devbox-nvim-data:/home/developer/.local/share/nvim
# Optional: persist MemPalace data (conversation memory, knowledge graph,
# embeddings). Without this, palace data is lost on container recreation.
# embeddings). Applies to the LOCAL palace only (the default, when
# MEMPALACE_REMOTE_URL is unset). Without this, palace data is lost on
# container recreation. In EXTERNAL mode the shared server owns the data,
# so this volume is irrelevant.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
+7 -1
View File
@@ -33,7 +33,7 @@ fi
# directly.
SKEL_DIR="/etc/skel-devbox"
if [ -d "$SKEL_DIR" ]; then
for f in .bash_aliases .inputrc; do
for f in .bash_aliases .inputrc .gitignore_global; do
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
cp "$SKEL_DIR/$f" "$HOME/$f"
fi
@@ -92,6 +92,12 @@ fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
# it only if the user has not already set their own core.excludesFile.
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
git config --global core.excludesFile "$HOME/.gitignore_global"
fi
# ── Generate opencode config from env vars if no config mounted ──────
# Delegated to a standalone Python script for clarity and testability.
+11
View File
@@ -0,0 +1,11 @@
# Global gitignore — personal/tooling artifacts (applies to all repos in the container)
# Seeded into $HOME/.gitignore_global by entrypoint-user.sh and wired via
# `git config --global core.excludesFile`. Edit freely; it is yours after first boot.
# backup / editor / merge artifacts
*.bak
*.bak.*
*~
*.orig
*.swp
*.tmp
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
#
# WHY THIS EXISTS
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
# and the editor offers "refresh from disk". This helper closes the loop:
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
# Studio to see the update. Uses mtime polling (no inotify dependency,
# which isn't in the trixie-slim base).
#
# USAGE
# dot-watch <file.dot> [layout] [dpi]
# layout: dot|neato|fdp|circo|twopi (default: dot)
# dpi: output resolution (default: 150)
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
#
# EXAMPLES
# dot-watch /workspace/graph.dot
# dot-watch graph.dot neato 200
set -euo pipefail
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
LAYOUT="${2:-dot}"
DPI="${3:-150}"
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
OUT="${SRC%.dot}.png"
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
trap 'rm -f "$ERRLOG"' EXIT
render() {
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
else
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
sed 's/^/ /' "$ERRLOG"
fi
}
# portable mtime (GNU stat, fallback to BSD stat)
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
render
last="$(mtime "$SRC")"
while true; do
sleep "$INTERVAL"
[[ -f "$SRC" ]] || continue
now="$(mtime "$SRC")"
if [[ "$now" != "$last" ]]; then
last="$now"
render
fi
done
@@ -16,9 +16,13 @@ Environment variables:
OPENCODE_MODEL Optional. Overrides the provider default model.
AWS_REGION Bedrock only. Default: us-east-1.
AWS_PROFILE Bedrock only. Default: default.
MEMPALACE_REMOTE_URL Optional. Point MemPalace at a shared HTTP endpoint
instead of spawning a local stdio server.
MEMPALACE_REMOTE_TOKEN Optional. Bearer token for MEMPALACE_REMOTE_URL.
MCP servers are auto-registered for tools detected on PATH:
- mempalace (if installed) enabled
- mempalace LOCAL stdio if `mempalace-mcp` is installed, or EXTERNAL
(remote HTTP) when MEMPALACE_REMOTE_URL is set (shared palace)
- gitea-mcp (if installed) registered but disabled by default
Output path: $HOME/.config/opencode/opencode.jsonc
@@ -80,13 +84,27 @@ def register_mcp_servers(config: dict) -> list[str]:
"""
servers: dict[str, dict] = {}
# MemPalace — local-first AI memory (if installed).
# `mempalace-mcp` is the entry-point binary shipped by the mempalace
# Python package. `uv tool install mempalace` places it on PATH as a
# shim whose shebang points at the isolated venv's Python, so system
# `python3 -m mempalace.mcp_server` (which would fail — system
# python3 can't import from the uv venv) is unnecessary here.
if shutil.which("mempalace-mcp"):
# MemPalace — AI memory. Two modes, same env contract as the mempalace.ts
# pi extension (mempalace-toolkit), so one shared MemPalace can serve pi +
# opencode + native:
# LOCAL (default): stdio subprocess. `mempalace-mcp` is the entry-point
# binary shipped by the mempalace Python package. `uv tool install
# mempalace` places it on PATH as a shim whose shebang points at the
# isolated venv's Python, so system `python3 -m mempalace.mcp_server`
# (which would fail — system python3 can't import from the uv venv) is
# unnecessary here.
# EXTERNAL: set MEMPALACE_REMOTE_URL to a shared MemPalace HTTP endpoint
# (e.g. http://mempalace.lan:8765/mcp). MEMPALACE_REMOTE_TOKEN, if set,
# becomes a Bearer auth header. No local mempalace-mcp is spawned, so
# the binary need not even be installed in this mode.
remote_url = os.environ.get("MEMPALACE_REMOTE_URL", "").strip()
if remote_url:
entry: dict = {"type": "remote", "url": remote_url}
token = os.environ.get("MEMPALACE_REMOTE_TOKEN", "").strip()
if token:
entry["headers"] = {"Authorization": f"Bearer {token}"}
servers["mempalace"] = entry
elif shutil.which("mempalace-mcp"):
servers["mempalace"] = {
"type": "local",
"command": ["mempalace-mcp"],
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Gitea-accurate guard against the "bash syntax under the default sh/dash
# shell" footgun. Ported from pi-devbox, where this class bit twice
# (ed49b8d resolve-versions; b7197e8/b33e9dc promote-base-latest, run 418).
# opencode-devbox has not been bitten yet — this is a PREVENTIVE guard so a
# future author can't reintroduce the class.
#
# WHY A CUSTOM CHECK AND NOT JUST actionlint:
# actionlint models *GitHub* Actions, whose default `run` shell is bash. It
# therefore assumes a step that omits `shell:` runs under bash, and does NOT
# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the
# exact bug (omit `shell:`, use bash syntax) is invisible to actionlint.
# actionlint only fires when a step *explicitly* declares `shell: sh`.
#
# THE INVARIANT THIS ENFORCES:
# Every `run:` step in every .gitea/workflows/*.yml must resolve to an
# effective shell of `bash` — via the step's own `shell:`, a job-level
# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step
# that would fall through to Gitea's `sh` default is a FAILURE, because a
# future author adding bash syntax to it fails silently in CI.
#
# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax,
# expression errors, and much else). Together they cover the class on Gitea.
set -euo pipefail
WF_DIR="${1:-.gitea/workflows}"
python3 - "$WF_DIR" <<'PY'
import sys, glob, os
try:
import yaml
except ImportError:
sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n")
sys.exit(2)
wf_dir = sys.argv[1]
files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml")))
if not files:
sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n")
sys.exit(2)
problems = []
for f in files:
with open(f) as fh:
doc = yaml.safe_load(fh) or {}
wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell"))
jobs = doc.get("jobs") or {}
for jname, job in jobs.items():
job = job or {}
job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell"))
steps = job.get("steps") or []
for i, step in enumerate(steps):
step = step or {}
if "run" not in step:
continue # `uses:` steps have no shell
eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh
if eff != "bash":
name = step.get("name") or f"step[{i}]"
problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)")
if problems:
sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n")
for p in problems:
sys.stderr.write(f" - {p}\n")
sys.exit(1)
print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.")
PY
+13
View File
@@ -3,6 +3,7 @@
#
# Verifies:
# - Core binaries are on PATH and runnable
# - non-modal editors nano + micro are present (alongside nvim)
# - opencode itself starts and prints a version
# - Entrypoint runs cleanly as non-root after UID adjustment
# - Generated opencode.json has the expected shape
@@ -118,6 +119,8 @@ run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
run "nvim" "nvim --version | head -1"
run "nano" "nano --version | head -1"
run "micro" "micro --version"
run "bat" "bat --version"
run "eza" "eza --version | head -1"
run "zoxide" "zoxide --version"
@@ -128,12 +131,22 @@ run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
run "yq" "yq --version"
run "git-crypt" "git-crypt --version | head -1"
run "gitleaks" "gitleaks version"
run "aws" "aws --version"
run "gitea-mcp" "gitea-mcp --version"
run "gosu" "gosu --version"
run "tmux" "tmux -V"
run "pandoc" "pandoc --version | head -1"
run "graphviz (dot)" "dot -V"
run "tldr (tealdeer)" "tldr --version"
run "dot-watch" "test -x /usr/local/bin/dot-watch && bash -n /usr/local/bin/dot-watch && echo ok"
# Background subagents: opencode gates them behind this experimental env var,
# and OMOS V2+ default orchestration depends on it. Baked ON as an ENV in
# Dockerfile.base — assert it's present in the image environment (both variants).
run_expect "bg-subagents env baked" "printenv OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" "true"
# SSH ControlMaster baked defaults: the config file must exist (image-level)
# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary