Compare commits

...

39 Commits

Author SHA1 Message Date
joakimp 6dfbded9c8 release: v1.6.2 — lift smoke size threshold (3500→3800)
Publish Docker Image / resolve-versions (push) Successful in 12s
Lint / hadolint (push) Successful in 7s
Lint / actionlint (push) Successful in 20s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 4m42s
Publish Docker Image / smoke-studio (push) Successful in 8m24s
Publish Docker Image / build-variant-studio (push) Successful in 18m8s
Publish Docker Image / build-variant (push) Successful in 25m53s
Publish Docker Image / promote-base-latest (push) Successful in 6s
Publish Docker Image / update-description (push) Successful in 12s
Completes v1.6.1's studio publish. Run 512 shipped v1.6.1 non-studio
(3411 MB) cleanly, but smoke-studio failed the size gate at 3574 MB vs
the 3500 MB threshold. Threshold was set in v1.0.0 pre-agent-browser
(baseline was 3.20 GB local arm64 + 300 MB margin); v1.6.0 baked in
agent-browser + Chromium (+~291 MB net) but the threshold was never
lifted. v1.6.0 never got to smoke because of the network fault, so
nothing surfaced this until v1.6.1's smoke-studio.

Bump SIZE_THRESHOLD_MB to 3800 (~225 MB margin above observed studio
number, tight enough to still catch a genuine +GB regression). Refresh
the comment above the constant with the current baseline + run 512
actuals so future readers know where the number came from.

CI-only change; image bytes identical to v1.6.1 except for the manifest's
release_tag/source_revision. Not base-affecting.
2026-07-23 08:43:57 +02:00
joakimp 45b6239777 test(smoke): don't hard-code a 'v' prefix on release_tag
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 21s
Publish Docker Image / resolve-versions (push) Successful in 48s
Publish Docker Image / base-decide (push) Successful in 28s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 4m44s
Publish Docker Image / smoke-studio (push) Failing after 5m4s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / build-variant (push) Successful in 29m5s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 15s
The smoke workflow deliberately passes RELEASE_TAG=smoke to the variant
build so smoke images don't collide with real vX.Y.Z tags. The variant
bakes that into /etc/pi-devbox/build-manifest.json, and pi-devbox-version
prints 'pi-devbox smoke' — correct behaviour. But the smoke assertion
required substring 'pi-devbox v', which only holds for real releases.

Assertion never fired before because pi-devbox-version was added after
v1.5.0 (fb49828, 2026-07-15) and every CI attempt since was blocked
before smoke ran (v1.6.0 network flake, v1.6.1 first successful base
build hit this). CI run 509 (v1.6.1) surfaced it.

Fix: require the substring 'pi-devbox ' (space, no v). The two
neighbouring assertions on --json and --quiet already cover the value
of release_tag; this one just verifies the human line renders.
Everything else in 55/56 checks passed on run 509 including
pi 0.81.1 reported and base-229f04e5d021 pushed OK.
2026-07-23 07:58:22 +02:00
joakimp d00eef2acb docs(changelog): v1.6.1 — pi 0.80.6→0.81.1 (skips 0.81.0)
Publish Docker Image / resolve-versions (push) Successful in 7s
Lint / actionlint (push) Successful in 55s
Lint / hadolint (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 55s
Publish Docker Image / build-base (push) Successful in 58m42s
Publish Docker Image / smoke (push) Failing after 4m50s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 13m22s
Publish Docker Image / build-variant-studio (push) Has been skipped
v1.6.0 was tagged 2026-07-17 but never reached Docker Hub (variant
publish blocked by a site-network SYN-drop fault, since fixed). Cut
v1.6.1 to land v1.6.0's content (agent-browser + pi-devbox-version)
alongside a first pi bump since v1.5.0.

0.81.0 is skipped deliberately: it removed the default stream fallback
for extensions using the pre-0.81 pi-agent-core API, which
pi-observational-memory relies on via agentLoop + stream.result().
0.81.1 restored the fallback (earendil-works/pi#6915), so 0.81.1 — but
not 0.81.0 — is a safe drop-in. pi-fork only imports types; unaffected.

Audit of 0.80.7–0.81.1 vs the two baked extensions and the base image:
no breaking changes affect pi-devbox. Node engine bumped to >=22.19.0
in 0.81.0 (nodesource 22.x currently 22.23.1, so no engine bump needed).

Base-affecting via the npm install line, so base-<hash> rebuilds.
2026-07-22 22:53:55 +02:00
joakimp fb35c549b5 fix(base): locate agent-browser's chrome arch-agnostically
Lint / actionlint (push) Failing after 12s
Lint / hadolint (push) Successful in 12s
Publish Docker Image / resolve-versions (push) Failing after 26s
Publish Docker Image / base-decide (push) Has been skipped
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Has been skipped
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 31m44s
Publish Docker Image / build-variant-studio (push) Has been skipped
v1.6.0's first build failed on amd64: playwright@latest fetches Chrome for
Testing, which extracts to chromium-<rev>/chrome-linux64/ on amd64 (vs
chrome-linux/ on arm64). The hardcoded chrome-linux/ glob matched only arm64,
so amd64 built no /usr/local/bin/agent-chrome symlink and 'test -x' failed
(agent-browser --version had already printed 0.32.1 — the tell).

Replace the glob with an arch-agnostic 'find -name chrome -path */chromium-*/*'
(skips the chrome-headless-shell binary and the chromium_headless_shell dir),
guarded by [ -n ] + the existing test -x. Verified locally: resolves the CfT
chrome and agent-browser drives it headless. hadolint clean.
2026-07-17 17:55:21 +02:00
joakimp 649fc44c5b chore(release): v1.6.0
Lint / hadolint (push) Failing after 22s
Lint / actionlint (push) Successful in 26s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Failing after 25m26s
Publish Docker Image / smoke (push) Has been skipped
Publish Docker Image / smoke-studio (push) Has been skipped
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Roll the unreleased changes into v1.6.0 (minor — significant base addition:
agent-browser + headless Chromium baked into every variant for browser
automation/front-end verification). Also includes the pi-devbox-version command
and the bundled pi-toolkit sonnet-5 template bump. No pi bump — CI resolves
latest pi from npm at build time (verified: 0.80.6→0.80.10 has no breaking
changes affecting pi-devbox).
2026-07-17 17:19:56 +02:00
joakimp 89a8dc7fab perf(base): trim agent-browser footprint ~960MB→~625MB before release
Lint / hadolint (push) Failing after 14s
Lint / actionlint (push) Successful in 23s
Size concern ahead of the v1.6.0 base rebuild. agent-browser drives the full
chrome (verified headless: open/title/eval with the headless_shell removed), so
Playwright's chromium_headless_shell-* build is dead weight — drop it (~334MB),
and clean apt/npm caches in-layer. Net browser footprint ~625MB/arch.

CI disk is otherwise fine: build-base's 'Reclaim runner disk' step frees
~20-30GB (hostedtoolcache/dotnet/android/jvm) + docker prune before buildx.
hadolint clean.
2026-07-17 17:19:20 +02:00
joakimp 6625d66f3a docs(agents): point the global AGENTS.md managed block at agent-browser
Lint / hadolint (push) Failing after 7s
Lint / actionlint (push) Successful in 27s
Discoverability follow-up to baking agent-browser into the base: agents won't
reach for a browser they don't know they have (the exact gap that made this
capability easy to miss). Add a short pointer section to the pi-devbox managed
block — capability, the preset AGENT_BROWSER_EXECUTABLE_PATH, and
`agent-browser skills get core --full` for the command set. Pointer only; depth
stays in the skill. rootfs change → folds into the same base-<hash> rebuild.
CHANGELOG note updated.
2026-07-17 17:15:09 +02:00
joakimp 8caafc3f49 base: bake agent-browser + Playwright Chromium (headless browsing, all variants)
Lint / actionlint (push) Successful in 28s
Lint / hadolint (push) Successful in 11s
Gives the agent a real browser it can drive so front-end work involving live
DOM/WebGL can be VERIFIED, not guessed. The agent-browser skill (from the
skillset repo) was a no-op without the binary; it now works out of the box.

- agent-browser CLI (standalone Rust, ships no browser) via npm, prefixed
  NPM_CONFIG_PREFIX=/usr so it survives the ~/.pi/npm-global volume.
- Chromium fetched with 'playwright install --with-deps chromium' into
  PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright — a system path that
  the /home/developer volume can't shadow (unlike agent-browser's own
  ~/.agent-browser/browsers default, which WOULD vanish on recreate).
- Stable /usr/local/bin/agent-chrome symlink, exported as
  AGENT_BROWSER_EXECUTABLE_PATH, insulates the ENV from Playwright's
  per-version chromium-<rev> dir name.

Verified end-to-end (this session): agent-browser drives the baked Chromium
headless (open + title + screenshot + eval into a WebGL SPA); doctor launch
test passes in ~0.5s. Debian trixie --with-deps resolution verified (exit 0;
t64 lib renames handled). hadolint clean; check-base-hash OK (only *_VERSION
args added). Cost ~960 MB (Chromium + headless shell). Base-affecting →
rebuilds base-<hash> on next release.
2026-07-17 16:12:47 +02:00
joakimp 71b12a9ed4 docs(skill): note macOS NFD-filename gotcha for dscp/scp
Lint / hadolint (push) Failing after 7s
Lint / actionlint (push) Successful in 31s
Accented filenames on a macOS host are stored decomposed (NFD), so a precomposed
(NFC) remote path in scp/dscp silently fails with 'No such file or directory'.
Document the wildcard / list-first workaround in the pi-devbox-environment skill,
next to the dssh/dscp alias table. (Hit while copying a screenshot named
'Skärmavbild ….png' from the host.)
2026-07-17 13:06:22 +02:00
joakimp fb49828826 base: add pi-devbox-version command + startup banner
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 1m11s
Wraps /etc/pi-devbox/build-manifest.json (already written at docker-build
time in Dockerfile.variant) into a human-readable summary instead of
requiring users to know the manifest path and pipe it through jq
themselves.

- rootfs/usr/local/bin/pi-devbox-version: human (default) / --json /
  --quiet output modes. Also flags live drift — compares the baked
  pi_version against the actually-running `pi --version` and warns
  on mismatch rather than trusting the manifest blindly (same
  ground-truth philosophy as the manifest generation itself). Exits 1
  with a short stderr notice on images built before the manifest
  existed, instead of failing silently.
- entrypoint-user.sh: calls it as the very first line. CMD is
  `bash -l` with tty:true in compose, so this banner lands directly
  above the first prompt on container start — no separate motd/bashrc
  hook needed (deliberately not wired into .bash_aliases, which would
  reprint on every docker exec -it).
- Dockerfile.base: COPY + chmod, same pattern as dot-watch/studio-expose.
- scripts/smoke-test.sh: 4 new checks (binary present+executable, human
  output has release tag, --json round-trips the manifest, --quiet is
  a single line).
- README.md / AGENTS.md / CHANGELOG.md updated.
2026-07-15 14:50:47 +02:00
pi 02be95ac1f docs(changelog): note bundled pi-toolkit sonnet-5 template bump (Unreleased)
Lint / hadolint (push) Successful in 10s
Lint / actionlint (push) Successful in 21s
2026-07-13 23:13:24 +02:00
pi d68674d11e chore(release): v1.5.0
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / base-decide (push) Successful in 9s
Lint / hadolint (push) Successful in 12s
Lint / actionlint (push) Successful in 13s
Publish Docker Image / build-base (push) Successful in 43m20s
Publish Docker Image / smoke (push) Successful in 4m20s
Publish Docker Image / smoke-studio (push) Successful in 7m2s
Publish Docker Image / build-variant (push) Successful in 16m18s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Publish Docker Image / build-variant-studio (push) Successful in 17m47s
Roll the 7 unreleased commits since v1.4.0 into v1.5.0 (minor — significant
base additions: readable Neovim true-colour, broad terminal terminfo support).
Also includes: typst PDF template font fix, .claude gitignore seed, pi-studio
semver-tag pin + version label, and repo hygiene (LICENSE, THIRD_PARTY.md,
.dockerignore, hadolint lint, IDEAS backlog). No pi bump — CI resolves latest
pi from npm at build time as usual.
2026-07-13 18:56:51 +02:00
pi 8c27894cf2 base: support modern terminals (ncurses-term + xterm-ghostty alias)
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 15s
The base only shipped ncurses-base (xterm-256color, tmux), so SSHing in from
WezTerm/Alacritty/foot/Ghostty degraded to a dumb TERM fallback. Following the
maintainer's ansible common role:

- Dockerfile.base: install ncurses-term (terminfo for wezterm, alacritty,
  foot, st, base ghostty entry, and many more).
- rootfs/.../terminfo-src/ghostty.terminfo: thin xterm-ghostty alias
  (use=ghostty) — Ghostty connects as TERM=xterm-ghostty, which no distro
  packages. Compiled into the system db with 'tic -x'; build asserts it
  resolved via infocmp.
- kitty (xterm-kitty) already covered by kitty-terminfo; iTerm2 uses
  xterm-256color (ncurses-base).
- smoke-test: assert ncurses-term emulators + xterm-ghostty alias resolve.

Validated end-to-end in a throwaway container: ncurses-term brings the
entries, the alias compiles and equals the ghostty capability set. hadolint
clean, bash -n OK. Base-affecting, rebuilds base-<hash>. No tag.
2026-07-13 18:45:38 +02:00
pi 291ae5345e repo: add LICENSE, THIRD_PARTY.md, .dockerignore, hadolint lint, IDEAS backlog
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 16s
Repo/CI hygiene batch (none base-affecting; image contents unchanged):

- LICENSE: actual MIT file (repo previously declared MIT only in prose).
- THIRD_PARTY.md: notes bundled software + licenses (pi/pi-fork/pi-obsmem/
  pi-studio MIT, gosu Apache-2.0, Debian packages under their own terms).
- .dockerignore: trims build context to what the Dockerfiles COPY (rootfs/ +
  entrypoint*.sh); keeps .git/docs/scripts/compose out. Verified it excludes
  none of the required COPY sources.
- lint.yml: new hadolint job (pinned v2.14.0) lints both Dockerfiles;
  .hadolint.yaml grandfathers deliberate choices (DL3008/DL3016/DL4006/DL3003/
  SC2086, mirroring the shellcheck excludes), fails on anything new at warning+.
  Verified hadolint exit 0 and the repo shell-guard passes with the new job.
- IDEAS.md: parks deferred follow-ups (SHA-pin actions, trivy, buildx SBOM/
  provenance, Makefile, renovate).
- README/DOCKER_HUB License sections now link LICENSE + THIRD_PARTY.md.

No tag.
2026-07-13 18:20:44 +02:00
pi 38d8832d34 ci: record pi-studio version as image label
Lint workflows / actionlint (push) Successful in 1m16s
Adds se.jordbo.pi-devbox.pi-studio-version (e.g. v0.9.36) alongside the
existing SHA label, so 'docker inspect' shows the human-readable version.

Plumbing: resolve-versions exposes studio_tag output -> both studio build
steps pass --build-arg PI_STUDIO_VERSION -> Dockerfile.variant declares
ARG PI_STUDIO_VERSION=none and emits the LABEL. Studio-variant only.

Validated: yq parse OK, resolve run-script bash -n OK. No tag.
2026-07-13 17:59:59 +02:00
pi 32586f19e7 ci: pin pi-studio to newest semver tag, not main HEAD
Lint workflows / actionlint (push) Successful in 23s
Upstream omaclaren/pi-studio stopped publishing GitHub Releases at v0.5.55
but keeps tagging every version (v0.9.36 now) and pushing to main. Tracking
main HEAD risked baking half-finished commits that land after a tag.

resolve-versions now lists all tags via a single git ls-remote (the REST
tags API paginates at 100 and the repo has >140 tags, so a single page can
miss the newest), picks the highest X.Y.Z with sort -V (pre-releases
excluded), and peels it to a commit SHA for PI_STUDIO_REF. SHA (not moving
tag) keeps cache-busting + reproducibility; require_sha still enforced.

Studio-variant only, not base-affecting. No change to the resolved commit
today (v0.9.36 == current main HEAD). Validated: yq parse OK, bash -n OK,
live git ls-remote -> v0.9.36 -> 2ef38ef. No tag.
2026-07-13 17:54:56 +02:00
pi aaf1be0bcb base: readable Neovim colours out of the box (termguicolors + kitty-terminfo)
Lint workflows / actionlint (push) Successful in 22s
Vanilla Neovim fell back to a 256-colour palette over ssh/kitty and rendered
strings/comments in a muddy, low-contrast colour. Fix, all base-affecting:

- rootfs/etc/xdg/nvim/sysinit.vim: system vimrc enabling termguicolors. Loads
  for every user before any personal ~/.config/nvim; overridable per-user.
- Dockerfile.base: install kitty-terminfo (TERM=xterm-kitty understood; lets
  Neovim auto-detect true colour) and set ENV COLORTERM=truecolor.
- smoke-test.sh: assert xterm-kitty terminfo present and nvim tgc default on.
- CHANGELOG (Unreleased/Added) + README editor note.

No tag — lands on the next base-<hash> rebuild.
2026-07-12 23:19:19 +02:00
pi 92212fa447 base: seed global gitignore with .claude/settings.local.json
Lint workflows / actionlint (push) Successful in 23s
Claude Code's per-machine local settings can carry credentials and should never
be committed. Add the pattern to the seeded ~/.gitignore_global so fresh
containers match a host global that already ignores it. Existing containers are
unaffected (seed copied only when absent). Base-affecting; rebuilds base-<hash>.
2026-07-11 22:29:28 +02:00
pi 3d46c6615e fix(base): default pandoc typst template font so PDF export works without -V mainfont
Lint workflows / actionlint (push) Successful in 21s
pandoc's bundled typst template defaults the document font to an empty
tuple (font: ()), so a naked `pandoc --pdf-engine=typst` fails with
"font fallback list must not be empty". Patch the template default to
Libertinus Serif (typst's own bundled default) at build time so PDF
export works out of the box. Document usage in README and note the fix
in CHANGELOG (Unreleased). Base-affecting.
2026-07-11 20:55:50 +02:00
pi fa6e9dc9d6 release: v1.4.0 — typst PDF engine + host SSH startup check
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 9s
Lint workflows / actionlint (push) Successful in 1m9s
Publish Docker Image / build-base (push) Successful in 34m32s
Publish Docker Image / smoke-studio (push) Successful in 4m16s
Publish Docker Image / smoke (push) Successful in 10m52s
Publish Docker Image / build-variant-studio (push) Successful in 18m1s
Publish Docker Image / build-variant (push) Successful in 18m51s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 18s
Finalize the Unreleased batch as v1.4.0 (minor — significant base
additions). Base rebuilds (Dockerfile.base for typst/xz-utils;
.bash_aliases for the SSH check). pi auto-resolves latest (0.80.3 ->
0.80.6); mempalace stays 3.5.0 (current).
2026-07-11 17:15:11 +02:00
joakimp bd0627a557 docs: note host SSH startup check in CHANGELOG Unreleased
Lint workflows / actionlint (push) Successful in 1m18s
2026-07-11 17:07:55 +02:00
pi 67da05b99b feat(base): ship typst as lightweight pandoc PDF engine
Lint workflows / actionlint (push) Successful in 15s
pandoc has been in the base since v1.0.0 but only as a front-end; PDF
export (studio_export_pdf / pandoc -o out.pdf) failed with 'xelatex not
found' because no back-end engine was installed. Ship typst (~30 MB
static Rust binary, no LaTeX) as the default engine via
`pandoc --pdf-engine=typst`, chosen over a ~600 MB TeX Live install.
texlive-xetex remains the higher-fidelity install-on-demand fallback.

- Dockerfile.base: install typst (latest GitHub-release idiom, pin via
  TYPST_VERSION); add xz-utils (typst ships .tar.xz); bump
  BASE_REBUILD_DATE. Lands in base-<hash>.
- smoke-test.sh: verify typst + a real pandoc --pdf-engine=typst render.
- README/AGENTS/CHANGELOG: typst now shipped (supersedes the planned
  :latest-studio-tex variant).

No tag pushed — CI build intentionally deferred.
2026-07-11 17:04:19 +02:00
joakimp 4563b4d76d feat: warn at shell startup if Mac host SSH is not reachable
Lint workflows / actionlint (push) Successful in 14s
Adds a _devbox_check_host_ssh() check to ~/.bash_aliases (baked into
the image). On first bash session of each container it tries a quick
SSH probe to the Mac host; if it fails it prints a clear one-time
warning with the exact two steps needed to fix it:

  1. Enable Remote Login in macOS System Settings
  2. echo '<public key>' >> ~/.ssh/authorized_keys

The check is guarded:
  - only runs inside a container (/.dockerenv)
  - only when the jump key exists (~/.ssh-local/devbox_jump_ed25519.pub)
  - only once per container lifetime (/tmp flag, cleared on recreate)

After --force-recreate the key changes, the flag is gone, and the
check runs again on the first bash window. Subsequent windows are
silent.
2026-07-11 15:26:23 +02:00
pi f19c35da32 docs: note lightweight PDF engine (typst) as preferred over full texlive
Lint workflows / actionlint (push) Successful in 36s
PDF export from Studio/pandoc still isn't shipped. Record the engine decision
in the living docs (README + AGENTS): pandoc is in the image but has no PDF
back-end, so export fails with 'xelatex not found'. Prefer a lightweight engine
— typst (~30 MB static binary, 'pandoc --pdf-engine=typst') which is small
enough it could ship in base rather than needing a separate ':latest-studio-tex'
variant; texlive-xetex (~600 MB) kept as the higher-fidelity fallback. Also drop
stale 'v1.3.0' pins (v1.3.0 already shipped without PDF) in favour of 'a future
release'. CHANGELOG history left untouched.
2026-07-09 16:25:54 +02:00
pi 6002c6299d release: v1.3.0 — shared/external MemPalace + nano/micro editors + CI lint
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / base-decide (push) Successful in 8s
Lint workflows / actionlint (push) Successful in 19s
Publish Docker Image / build-base (push) Successful in 33m17s
Publish Docker Image / smoke (push) Successful in 3m55s
Publish Docker Image / smoke-studio (push) Successful in 6m50s
Publish Docker Image / build-variant (push) Successful in 16m12s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / build-variant-studio (push) Successful in 20m50s
Promotes the Unreleased block to v1.3.0. Bundled contents:
- feat: shared/external MemPalace — mempalace.ts bridge honours MEMPALACE_REMOTE_URL;
  adds docker-compose.mempalace.yml.
- feat: nano + micro non-modal editors (Dockerfile.base → base rebuild).
- ci: lint.yml (sh-vs-bash guard + actionlint/shellcheck), docker-publish.yml bash
  defaults, promote-base-latest shell fix.
pi stays 0.80.3 (== npm latest). Base rebuilds (mempalace-toolkit ref advanced +
Dockerfile.base nano/micro), so the new bridge + editors land in base-<hash>.
2026-07-02 14:50:17 +02:00
pi d73bf2e9d3 feat: optional shared/external MemPalace via MEMPALACE_REMOTE_URL
Lint workflows / actionlint (push) Successful in 21s
Wire the shared-palace option (implemented in mempalace-toolkit's mempalace.ts)
into the container:
- .env.example: document MEMPALACE_REMOTE_URL / MEMPALACE_REMOTE_TOKEN (env_file-only,
  per this repo's convention).
- docker-compose.mempalace.yml: optional shared server (mempalace-mcp --transport http),
  loopback-bound by default.
- docker-compose.yml: local-vs-external note on the palace-volume comment.
- README + CHANGELOG (Unreleased).
2026-07-02 13:09:29 +02:00
pi 3a59e15563 feat: ship nano + micro (non-modal editors) alongside nvim
Lint workflows / actionlint (push) Successful in 13s
The image shipped only nvim (EDITOR=nvim), a modal vi-style editor. Not
everyone is comfortable with vi keybindings, so add both a classic and a
modern non-modal option:

- 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 (<0.5% of the ~3.2 GB image). EDITOR stays nvim; both new
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.

Base-image change, so it lands on the next base-<hash> rebuild. Updates
README tool table + EDITOR note, CHANGELOG (Unreleased/Added), and
smoke-test.sh (nano + micro presence checks).
2026-07-01 23:11:25 +02:00
pi d1db595f17 ci(lint): pass explicit workflow paths to actionlint
Lint workflows / actionlint (push) Successful in 15s
actionlint's no-arg project auto-detection looks for .github/workflows
and hard-fails (exit 3, 'no project was found') on this .gitea/workflows
layout — observed on run 420. Glob the workflow files explicitly. The
Gitea shell guard step already passed in that run; only the actionlint
invocation needed the path fix.
2026-07-01 22:06:40 +02:00
pi 26384fe9f1 ci: eliminate the sh-vs-bash footgun class (defaults + lint guard)
Lint workflows / actionlint (push) Failing after 34s
Root cause of the recurring 'Illegal option -o pipefail' failures
(ed49b8d resolve-versions; b7197e8 promote-base-latest, run 418):
docker-publish.yml had no workflow-level default shell, so Gitea's
sh/dash default applied and every bash-syntax step had to individually
remember 'shell: bash'.

- docker-publish.yml: add 'defaults: run: shell: bash' — fixes the whole
  class; all pre-existing dash steps are POSIX so bash runs them unchanged.
- lint.yml: new workflow, runs on every push/PR (not just release tags):
    * scripts/check-workflow-shell.sh — Gitea-accurate guard: fails if any
      run: step doesn't resolve to bash. Catches the omit-shell+bash-syntax
      case that actionlint MISSES (actionlint models GitHub, where the
      default shell is bash, so a shell-less step is assumed bash).
    * actionlint + shellcheck — catches explicit 'shell: sh' + bash syntax
      (SC3040) and general workflow errors.
  Verified locally: guard + actionlint pass current workflows; guard fails
  a synthetic omit-shell+pipefail workflow; shellcheck clean.
2026-07-01 22:05:04 +02:00
pi b33e9dc592 fix(ci): promote-base-latest re-tag step needs shell: bash (set -o pipefail)
b7197e8 moved the digest-compare into the re-tag step with 'set -euo
pipefail' but no 'shell: bash'; Gitea's default sh (dash) aborts on
-o pipefail, leaving base-latest un-promoted on the v1.2.4 release
(run 418). Same footgun as ed49b8d. Consumer tags unaffected (they
FROM base-<hash>, not base-latest).
2026-07-01 17:57:13 +02:00
pi 3cdc2069db release: v1.2.4 — pi 0.80.2 → 0.80.3; global gitignore, env_file-only secrets, promote-base-latest CI fix
Publish Docker Image / resolve-versions (push) Successful in 1m2s
Publish Docker Image / base-decide (push) Successful in 43s
Publish Docker Image / build-base (push) Successful in 41m46s
Publish Docker Image / smoke (push) Successful in 4m10s
Publish Docker Image / smoke-studio (push) Successful in 6m42s
Publish Docker Image / build-variant (push) Successful in 16m16s
Publish Docker Image / promote-base-latest (push) Failing after 4s
Publish Docker Image / update-description (push) Successful in 12s
Publish Docker Image / build-variant-studio (push) Successful in 17m52s
2026-07-01 16:49:25 +02:00
pi cc53877328 feat: bake global gitignore (core.excludesFile) into image
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
pi c42b237d30 compose: deliver secrets via env_file only (drop environment: passthrough)
Removes GITEA_ACCESS_TOKEN / GITEA_HOST / GITHUB_PERSONAL_ACCESS_TOKEN from
the compose environment: block. An environment: entry both overrides
env_file AND is interpolated from the host shell, so a stale shell export
(e.g. one auto-loaded by an opencode/dotenv hook) silently shadowed the
users .env — an updated token never reached the container. Secrets now flow
solely via env_file: .env; .env.example already documents every variable.

- docker-compose.yml: drop the 3 passthrough lines + explanatory comment
- README.md: sync the "basic shape" snippet
- CHANGELOG.md: note under Unreleased (no tag bump / unpublished)
2026-06-27 23:48:02 +02:00
pi b7197e88b0 ci(promote-base-latest): re-point base-latest by digest, not need_build
The 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 and skipped promotion, leaving base-latest one base
behind (observed 2026-06-27, v1.2.3 dry-run-first release).

Gate now runs on every tag release / promote dispatch; the no-op
optimization moved into the step as a crane digest compare so it re-tags
only when base-latest actually differs from the released base-<hash>.
Workflow-only change; base hash unaffected (no base rebuild).
2026-06-27 20:57:03 +02:00
pi 2985d9ade8 release: v1.2.3 — mempalace-mcp self-heal (toolkit e12b624)
Publish Docker Image / resolve-versions (push) Successful in 7s
Publish Docker Image / base-decide (push) Successful in 14s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 3m30s
Publish Docker Image / smoke-studio (push) Successful in 11m47s
Publish Docker Image / build-variant (push) Successful in 15m48s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / build-variant-studio (push) Successful in 17m28s
Patch release. Headline: mempalace-mcp self-heals instead of latching
available=false permanently after a slow virtiofs cold-open. Base image
rebuilds via the mempalace-toolkit ref advancing to e12b624 (folded into
the base-decide hash). No pi/mempalace version change — pi npm latest is
still 0.80.2 (= v1.2.2). Also releases the queued yq (mikefarah Go yq) and
mempalace-skill temporal-grounding changes.
2026-06-27 18:45:24 +02:00
pi bff810c1eb docs(dockerfile): sync mempalace stall-protection comment with self-heal
mempalace.ts now self-heals (respawn with capped backoff) instead of
latching unavailable, and the init-timeout default is 300000. Update the
explanatory comment + tunable list (MEMPALACE_MCP_MAX_RESPAWNS,
MEMPALACE_MCP_RESPAWN_BACKOFF_MS). Comment-only; no build/ENV change.
2026-06-26 00:22:43 +02:00
pi 904fe85249 skill(mempalace): teach temporal grounding (recreate != new day)
Baked mempalace SKILL.md now instructs agents to establish current date/time
and compute the delta against the actual diary/drawer timestamp before using
relative terms (yesterday/last week), and explicitly that a container recreate
or fresh session is NOT a day boundary (pi-devbox restarts several times a day).
Phase 1 wake-up section + anti-pattern bullet. CHANGELOG Unreleased.
2026-06-25 22:53:31 +02:00
pi cda488c565 base: yq follows latest (was pinned v4.53.3), gate on major v4
Match the repo's latest-following convention (tealdeer/uv/etc.) and keep the
container in sync with the Mac's brew yq. smoke-test now asserts mikefarah AND
major v4, so a surprise yq v5 fails CI instead of silently breaking
provision.sh. Pin still available via --build-arg YQ_VERSION=vX.Y.Z.
2026-06-25 16:33:27 +02:00
pi 9ab9a28458 base: install mikefarah yq (pinned v4.53.3), drop Debian python yq
Debian/Ubuntu `apt install yq` is kislyuk/yq (Python, v3.x), incompatible
with the mikefarah v4 syntax the cloud-init repo's provision.sh/deploy.sh
require. Replace the apt package with a pinned mikefarah Go binary, mirroring
the existing tealdeer ARG (latest-or-pin) pattern, multi-arch amd64/arm64.
smoke-test.sh now asserts `yq --version` reports mikefarah so CI catches a
regression. CHANGELOG: Unreleased entry.
2026-06-25 16:29:51 +02:00
27 changed files with 1586 additions and 63 deletions
+35
View File
@@ -0,0 +1,35 @@
# Keep the Docker build context minimal and prevent stray files (notably
# `.git`) from ever being pulled in by a future broad COPY. Both Dockerfiles
# only COPY `rootfs/` and `entrypoint*.sh`, so everything below is safe to
# exclude from the context.
#
# DO NOT add `rootfs/`, `entrypoint.sh`, `entrypoint-user.sh`, or the
# Dockerfiles here — they are required to build the image.
# VCS / CI metadata
.git
.gitea
.gitignore
.dockerignore
# Lint / editor config
.hadolint.yaml
.editorconfig
# Docs & project meta
README.md
DOCKER_HUB.md
CHANGELOG.md
AGENTS.md
IDEAS.md
LICENSE
THIRD_PARTY.md
docs
# Local orchestration & examples (compose runs the image; not a build input)
docker-compose.yml
docker-compose.mempalace.yml
.env.example
# Repo tooling / tests (run from a checkout, not baked into the image)
scripts
+13
View File
@@ -9,6 +9,19 @@ WORKSPACE_PATH=~/projects
# Path to SSH keys on host
SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ───────────────────────────
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation.
#
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
# 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=
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint
+83 -15
View File
@@ -38,6 +38,16 @@ 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 (hit twice: ed49b8d resolve-versions,
# b7197e8/b33e9dc promote-base-latest). All existing dash steps use only
# POSIX syntax, so bash (a superset) runs them unchanged.
defaults:
run:
shell: bash
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
@@ -125,6 +135,7 @@ jobs:
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
studio_tag: ${{ steps.resolve.outputs.studio_tag }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps:
- name: Resolve pi version + companion refs
@@ -189,16 +200,40 @@ jobs:
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
# pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio.
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true)
# pi-studio (omaclaren/pi-studio) → newest SEMVER TAG's commit SHA
# for the :*-studio images. Upstream stopped publishing GitHub
# *Releases* at v0.5.55 but keeps tagging every version (vX.Y.Z) and
# pushing to main, so pinning main HEAD risked baking half-finished
# commits that land after a tag. Take the newest stable tag instead.
# List ALL tags in one `git ls-remote` call — the REST tags API
# paginates at 100 and this repo already has >140 tags, so page 1 is
# NOT guaranteed to hold the newest — pick the highest X.Y.Z with
# `sort -V` (pre-releases like -rc1 excluded by the strict filter),
# then resolve its commit SHA (a SHA, not a moving tag, preserves
# cache-busting + reproducibility and is what require_sha demands).
STUDIO_TAGS=$(git ls-remote --tags "https://github.com/omaclaren/pi-studio.git" || true)
STUDIO_TAG=$(printf '%s\n' "$STUDIO_TAGS" | awk '{print $2}' \
| sed -n 's#^refs/tags/##p' \
| grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -n1 || true)
if [ -z "${STUDIO_TAG:-}" ]; then
echo "::error::Could not resolve a pi-studio semver tag (git ls-remote empty/unreachable). Refusing to fall back to a floating ref."
exit 1
fi
# Prefer the peeled ^{} line (annotated tags); fall back to the
# direct ref (lightweight tags, which pi-studio currently uses).
STUDIO_REF=$(printf '%s\n' "$STUDIO_TAGS" | awk -v t="refs/tags/${STUDIO_TAG}^{}" '$2==t{print $1}')
if [ -z "$STUDIO_REF" ]; then
STUDIO_REF=$(printf '%s\n' "$STUDIO_TAGS" | awk -v t="refs/tags/${STUDIO_TAG}" '$2==t{print $1}')
fi
require_sha PI_STUDIO_REF "$STUDIO_REF"
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
echo "Resolved PI_STUDIO_REF=${STUDIO_REF}"
echo "Resolved PI_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
@@ -380,6 +415,7 @@ jobs:
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
INSTALL_STUDIO=true
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
RELEASE_TAG=smoke-studio
SOURCE_REVISION=${{ github.sha }}
@@ -521,6 +557,7 @@ jobs:
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
@@ -543,6 +580,7 @@ jobs:
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "INSTALL_STUDIO=true" \
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
--build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
@@ -565,16 +603,19 @@ jobs:
needs:
- base-decide
- build-variant
# 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 on promote_latest=true dispatches).
# The job-level gate deliberately does NOT key off need_build anymore:
# the actual no-op optimization moved INTO the step as a 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. (Observed 2026-06-27,
# v1.2.3: dry-run-first release left base-latest one base behind.)
if: |
always() &&
needs.build-variant.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
@@ -596,11 +637,38 @@ 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)
# shell: bash is REQUIRED — Gitea Actions' default step shell is
# `sh -e {0}` (dash), which rejects `set -o pipefail` with
# "Illegal option -o pipefail" and aborts the step before the
# crane digest-compare runs, leaving base-latest un-promoted.
# Same footgun as ed49b8d (resolve-versions). Regression shipped
# in b7197e8, caught on the v1.2.4 release (run 418).
shell: bash
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:
+93
View File
@@ -0,0 +1,93 @@
name: Lint
# Durable guard against CI-workflow bugs — most importantly the recurring
# "bash-only syntax under the default `sh`/dash shell" footgun that 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 the build workflow (docker-publish.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
hadolint:
# Lint the two Dockerfiles that ARE the project (the shell/actions linting
# above never looked at them). Config — ignored rules + failure threshold
# — lives in .hadolint.yaml, which hadolint reads automatically, so a local
# `hadolint Dockerfile.base` reproduces CI exactly.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install hadolint (pinned)
env:
HADOLINT_VERSION: 2.14.0
run: |
curl -fsSL \
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint
hadolint --version
- name: Run hadolint
run: hadolint Dockerfile.base Dockerfile.variant
+27
View File
@@ -0,0 +1,27 @@
# hadolint configuration for pi-devbox.
#
# Both Dockerfiles are linted in CI (.gitea/workflows/lint.yml → `hadolint`
# job). hadolint reads this file automatically, so a local
# `hadolint Dockerfile.base` reproduces CI exactly.
#
# The ignores below are DELIBERATE project choices — they mirror the
# philosophy of the shellcheck excludes already applied to `run:` steps
# (SHELLCHECK_OPTS in lint.yml). Anything NOT listed here still fails the
# build at `warning` and above, so new Dockerfile smells are caught going
# forward.
ignored:
- DL3008 # "pin apt versions" — intentionally unpinned: the base tracks
# Debian stable and runs `apt-get upgrade`, so pinning point
# versions would rot and fight security updates.
- DL3016 # "pin npm versions" — pi's version IS pinned, but via the
# PI_VERSION build-arg (CI-resolved from npm), not the npm CLI.
- DL4006 # "set -o pipefail before a pipe" — the piped RUNs are
# download|extract steps with their own retries / `set -e`.
# Switching the global SHELL to bash is a larger, base-affecting
# change — tracked in IDEAS.md.
- DL3003 # "use WORKDIR, not cd" — cosmetic in the few `cd` RUNs here.
- SC2086 # "double-quote to prevent word-splitting" — the same code is
# excluded for shell `run:` steps in lint.yml; splitting is
# intentional in these contexts.
failure-threshold: warning
+17 -10
View File
@@ -22,13 +22,16 @@ re-brand of opencode-devbox's `pi-only` variant.
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
(Option 1 over Option 2 — see `skills/VENDORED.md`).
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), image-baked
skills symlink-in, skillset deploy.
- `entrypoint-user.sh` — per-container start: prints the `pi-devbox-version`
banner first (which build/commit is running, from the manifest below),
then SSH ControlMaster socket dir, LAN-access setup, MemPalace init,
pi-toolkit + pi-extensions deploy, mempalace-bridge symlink, fork/recall +
pi-studio pi-install, optional `studio-expose` bridge (when
`STUDIO_EXPOSE=1`), image-baked skills symlink-in, skillset deploy.
- `rootfs/` — files baked into the image (bash aliases, inputrc,
setup-lan-access.sh, `studio-expose` helper). Also
setup-lan-access.sh, `studio-expose` helper, `pi-devbox-version` — wraps
`/etc/pi-devbox/build-manifest.json` into a human-readable summary + live
drift check, see README “Build provenance”). Also
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
@@ -153,10 +156,14 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## What we DON'T install (and why)
- **No texlive** (~600 MB1 GB). Users who need PDF export from pandoc
or pi-studio can install on demand: `sudo apt-get install texlive-xetex
texlive-latex-recommended`. The planned `:latest-studio-tex` variant
will bake this in.
- **No texlive** (~600 MB1 GB). PDF export from pandoc / pi-studio works
out of the box via **`typst`** (~30 MB static binary), which the base ships
as the pandoc PDF engine (`pandoc --pdf-engine=typst`) — small enough to live
in base rather than a dedicated `:latest-studio-tex` variant. We don't bake in
a full TeX Live: it's heavy and typst covers the common Markdown→PDF case.
Users needing LaTeX-exact output can install the higher-fidelity fallback on
demand: `sudo apt-get install texlive-xetex texlive-latex-recommended` (then
`pandoc --pdf-engine=xelatex`).
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
vendored to `/opt/pi-studio` and registered at container start via
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
+470
View File
@@ -11,6 +11,476 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## Unreleased
_Nothing yet — next release's changes will accrue here._
## v1.6.2 — 2026-07-23
Patch release. **Completes the v1.6.1 studio publish.** CI-only change; the
shipped image content is identical to v1.6.1 apart from the bumped `pi`
version resolution at build time (still `0.81.1`).
> **Note on v1.6.1.** Ran on 2026-07-23; the non-studio variant (`v1.6.1`,
> `latest`, `base-latest`) shipped cleanly, but the studio variant was blocked
> in the smoke-studio job by a size assertion that was still calibrated for
> the pre-`agent-browser` baseline. `v1.6.1-studio` and `latest-studio` were
> never pushed; `latest-studio` on Hub still points at v1.5.0-studio until
> v1.6.2 lands. Users who pull `joakimp/pi-devbox:v1.6.1` today get a valid
> non-studio image with `pi 0.81.1` baked; there is no `v1.6.1-studio` image.
### Fixed (CI)
- **`scripts/smoke-test.sh`: raise `SIZE_THRESHOLD_MB` from `3500` to `3800`.**
The 3500 threshold was set in v1.0.0 based on a local arm64 build measured
at 3.20 GB plus a `+300 MB` margin. v1.6.0 baked in `agent-browser` +
Playwright Chromium (~291 MB net, documented in v1.6.0's entry) but the
threshold was never updated — v1.6.0 never ran to smoke because of the
site-network fault, so nothing surfaced the miscalibration until
run 512 (v1.6.1) reached smoke-studio and reported
`3574 MB exceeds threshold 3500 MB`. Actual CI amd64 sizes observed on
run 512: **3411 MB non-studio**, **3574 MB studio**. The new 3800 MB
ceiling carries ~225 MB margin above the studio number — enough to absorb
minor arch/build-cache variance and small future growth, still tight
enough to catch a genuine +GB regression. The comment above the constant
is refreshed to reflect the new baseline (agent-browser included, run 512
actuals). Not base-affecting; base hash unchanged.
- **`scripts/smoke-test.sh`: don't hard-code a `v` prefix on `release_tag`
in the `pi-devbox-version` human-output assertion.** (Landed on the
retagged `v1.6.1` and carried forward in `v1.6.2`.) The smoke workflow
deliberately passes `RELEASE_TAG=smoke` / `RELEASE_TAG=smoke-studio` to
the variant build so smoke images don't collide with real `vX.Y.Z` tags,
and `pi-devbox-version` correctly prints `pi-devbox smoke`. The prior
assertion required the literal substring `pi-devbox v` — only true for
real releases — so it fired on every smoke run once it existed. The two
neighbouring assertions on `--json` and `--quiet` already cover the value
of `release_tag`; the human-output assertion now only verifies that the
line renders (substring `pi-devbox ` — note the trailing space). Never
fired before because `pi-devbox-version` was added post-v1.5.0 and every
CI attempt since was blocked before smoke ran.
## v1.6.1 — 2026-07-22
Patch release. Headline: **pi `0.80.6` → `0.81.1`** (npm `latest`) — the first
pi bump since v1.5.0.
> **Note on v1.6.0.** The `v1.6.0` git tag was cut on 2026-07-17 (agent-browser +
> `pi-devbox-version`, see below) but never reached Docker Hub: the variant
> publish was blocked by an intermittent SYN-drop fault on the on-prem CI
> network (`ci-network-diagnosis.md`, since resolved). v1.6.1 lands v1.6.0's
> content **plus** the pi bump in one release; there is no `v1.6.0` image on
> Docker Hub. The `v1.6.0` git tag is left in place as an accurate record of
> what was intended on that day.
### Changed
- **pi `0.80.6``0.81.1`.** The CI resolves `pi@latest` at build time; latest
is now `0.81.1`. The intermediate `0.81.0` is deliberately skipped: 0.81.0
removed the default stream fallback for extensions using the pre-0.81
`@earendil-works/pi-agent-core` API, which `pi-observational-memory` relies
on (`agentLoop` + `stream.result()` in the observer/reflector/dropper
agents). 0.81.1 restored the fallback ([earendil-works/pi#6915][pi-6915]),
making 0.81.1 — but not 0.81.0 — a safe drop-in. `pi-fork` only imports
types from `pi-agent-core` and is unaffected. Everything since v1.5.0's
baked `0.80.6` (i.e. `0.80.7``0.80.10`, `0.81.0`, `0.81.1`) was audited for
breaking changes against the two baked extensions — none affect this image.
The Node engine requirement rose to `>=22.19.0` in `0.81.0`; the base still
ships `22.23.1` (nodesource 22.x), so no engine bump is needed. Highlights
users inherit from the upstream jump: **local llama.cpp router support**
(search + download Hugging Face models, explicit load/unload, live
progress); **full pi-ai provider extensions** (extensions can now register
complete providers with native auth, model refresh, filtering, and
streaming); **Qwen Token Plan** subscription providers; **resilient
compaction / branch-summary retries** on transient provider failures with
lifecycle events exposed to interactive, JSON, RPC, and SDK consumers;
expanded usage accounting for tools, compaction, and branch summaries.
Base-affecting (npm install line rebuilds), so `base-<hash>` rebuilds. See
the [pi changelog][pi-changelog] for the full list.
[pi-6915]: https://github.com/earendil-works/pi/issues/6915
[pi-changelog]: https://github.com/earendil-works/pi/blob/main/CHANGELOG.md
## v1.6.0 — 2026-07-13
> ⚠️ **Never published to Docker Hub.** Tagged in git on 2026-07-17 but the
> variant publish was blocked by a site-network fault before the image reached
> the registry. Superseded by v1.6.1, which carries this release's content
> forward alongside the `pi 0.81.1` bump.
### Added
- **`agent-browser` — headless browser automation, baked into every variant.**
The base now ships the [`agent-browser`](https://www.npmjs.com/package/agent-browser)
CLI plus a Playwright-fetched Chromium, so the agent can drive a real browser
(open/click/fill/`eval`/screenshot/snapshot) and *verify* front-end work
involving live DOM or WebGL instead of guessing. The `agent-browser` skill
(from the skillset repo) was previously a no-op because the binary was
absent; it now works out of the box. Two pieces: the standalone Rust CLI
(npm, `NPM_CONFIG_PREFIX=/usr` so it survives the `~/.pi/npm-global` volume),
and a Chromium fetched via `playwright install --with-deps chromium` into
`PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright` (a system path,
never shadowed by the `/home/developer` volume — unlike agent-browser's own
`~/.agent-browser/browsers` default). A stable `/usr/local/bin/agent-chrome`
symlink, exported as `AGENT_BROWSER_EXECUTABLE_PATH`, insulates the config
from Playwright's per-version `chromium-<rev>` directory name. Debian trixie
`--with-deps` dependency resolution verified (the t64 renames are handled).
The global AGENTS.md managed block
(`rootfs/usr/local/share/pi-devbox/pi-global-AGENTS.append.md`) gains a short
pointer so agents discover the capability. Adds ~625 MB (Chromium; Playwright's
unused headless-shell build is dropped and the apt/npm caches cleaned in-layer
to stay lean). Base-affecting, rebuilds `base-<hash>`.
- **`pi-devbox-version` command.** Wraps `/etc/pi-devbox/build-manifest.json`
into a human-readable summary (release tag, build date, source revision,
baked `pi_version`, and short SHAs for every `/opt` component) instead of
requiring users to know the manifest path and pipe it through `jq`
themselves. Also flags **live drift** — if `pi --version` no longer matches
what was baked at build time, the `pi:` line calls that out rather than
silently trusting the manifest. `--json` dumps the raw manifest for
scripting; `--quiet` gives a one-line `release_tag (source_revision)` form.
Printed automatically once at container start (`entrypoint-user.sh`, before
the rest of the setup output), and stays available on demand for the rest
of the session. Exits 1 with a short notice — rather than failing silently
— on images built before this file existed. Base-affecting (new
`rootfs/usr/local/bin/pi-devbox-version`), rebuilds `base-<hash>`.
### Changed
- **Bundled `pi-toolkit` settings template: `pi-fork` balanced tier bumped to
`eu.anthropic.claude-sonnet-5`** (was `claude-sonnet-4-6`), matching the model
now in use. The image clones `pi-toolkit@main` into `/opt/pi-toolkit` at build
time, so the next build bundles it automatically (pi-toolkit `0010417`); the
same commit also refreshes the template's `enabledModels` and the README
examples. Seed-only: existing containers keep their live `~/.pi/agent/settings.json`
(the entrypoint merge is live-wins), so only fresh `~/.pi` volumes are affected.
## v1.5.0 — 2026-07-13
### Added
- **Seeded global gitignore now ignores `**/.claude/settings.local.json`.** Claude
Code's per-machine local settings file holds machine-specific permissions and
can carry credentials, so it should never be committed. The seed
(`rootfs/home/developer/.gitignore_global`, baked to `/etc/skel-devbox/`) gains
the pattern so fresh containers match a host global that already ignores it.
Existing containers are unaffected (the seed is copied only when
`~/.gitignore_global` is absent); their file can be updated by hand. Base-
affecting (`Dockerfile.base` COPY of the seed), rebuilds `base-<hash>`.
- **Readable Neovim colours out of the box.** The base now ships a system-wide
Neovim config (`/etc/xdg/nvim/sysinit.vim`) that enables `termguicolors`,
plus the `kitty-terminfo` package. Vanilla Neovim otherwise fell back to a
256-colour palette over ssh/kitty and rendered strings and comments in a
muddy, low-contrast dark colour. `sysinit.vim` is Neovim's system vimrc: it
loads for every user before any personal `~/.config/nvim` and can still be
overridden per-user (`:set notermguicolors`, or your own init). Base-affecting
(`Dockerfile.base` apt package + COPY), rebuilds `base-<hash>`.
- **Terminal support beyond kitty: `ncurses-term` + a compiled `xterm-ghostty`
alias.** The base previously shipped only `ncurses-base` (xterm-256color,
tmux), so SSHing in from a modern emulator degraded to a dumb fallback. The
base now installs `ncurses-term` (terminfo for WezTerm, Alacritty, foot, st,
and the base `ghostty` entry, among many others) and compiles an
`xterm-ghostty` alias with `tic -x` (`use=ghostty`) — Ghostty connects as
`TERM=xterm-ghostty` and no distro packages that name. Combined with
`kitty-terminfo` (xterm-kitty) and xterm-256color (iTerm2's default, already
in ncurses-base), the common modern terminals now resolve their TERM. The
approach mirrors the maintainer's ansible `common` role. Base-affecting
(`Dockerfile.base` apt + COPY + `tic` RUN, plus a new
`rootfs/usr/local/share/terminfo-src/ghostty.terminfo`), rebuilds `base-<hash>`.
- **Repository hygiene: `LICENSE`, `THIRD_PARTY.md`, and `.dockerignore`.** The
repo declared MIT only in prose; it now ships an actual `LICENSE` file (MIT,
© Joakim Persson) plus `THIRD_PARTY.md` recording that the published images
bundle third-party software under its own terms (pi, pi-fork,
pi-observational-memory, pi-studio — all MIT; gosu Apache-2.0; Debian packages
under their respective licenses). A new `.dockerignore` trims the build
context to what the Dockerfiles actually `COPY` (`rootfs/` + `entrypoint*.sh`),
keeping `.git`, docs, `scripts/`, and compose files out — cheaper context and
no risk of a future broad `COPY` pulling in `.git`. Not base-affecting (the
base hash covers only `Dockerfile.base` + `rootfs/` + `entrypoint*.sh`);
image contents are byte-identical.
- **Dockerfile linting (`hadolint`) in CI, plus an `IDEAS.md` backlog.** The
lint workflow already ran actionlint + shellcheck on `run:` steps but never
looked at the two Dockerfiles that are the heart of the project. A new
`hadolint` job (pinned v2.14.0, same download-pin pattern as actionlint) lints
`Dockerfile.base` and `Dockerfile.variant`; `.hadolint.yaml` grandfathers the
deliberate choices (unpinned apt/npm, `cd`-in-`RUN`, `SC2086` — mirroring the
existing shellcheck excludes) and fails on anything new at `warning`+.
`IDEAS.md` parks the vetted-but-unscheduled follow-ups (SHA-pin CI actions,
trivy scanning, buildx SBOM/provenance attestations, a local `Makefile`,
renovate). Repo/CI only — not baked into the image.
### Changed
- **`-studio` images now pin pi-studio to its newest *semver tag* instead of
`main` HEAD.** Upstream `omaclaren/pi-studio` abandoned GitHub *Releases* at
v0.5.55 but keeps tagging every version (currently `v0.9.36`) and pushing to
`main`; tracking `main` HEAD risked baking half-finished commits that land
after a tag. CI (`resolve-versions`) now lists every tag via a single
`git ls-remote` (the REST tags API paginates at 100 and the repo already has
>140 tags), selects the highest `X.Y.Z` with `sort -V` (pre-releases
excluded by a strict filter), and pins that tag's commit SHA into
`PI_STUDIO_REF`. Pinning the SHA (not the moving tag) preserves cache-busting
and reproducibility, is what `require_sha` demands, and is recorded in the
`se.jordbo.pi-devbox.pi-studio-ref` image label. The human-readable tag (e.g.
`v0.9.36`) is now also recorded in a new `se.jordbo.pi-devbox.pi-studio-version`
label for at-a-glance identification (`docker inspect`). Studio-variant only —
not base-affecting; takes effect on the next `-studio` build. No change to the
resolved commit today (`v0.9.36` == current `main` HEAD).
### Fixed
- **`pandoc --pdf-engine=typst` now works without `-V mainfont`.** pandoc's
bundled typst template (`/usr/share/pandoc/data/templates/template.typst`)
defaults the document font to an empty tuple (`font: ()`), so a naked
`pandoc --pdf-engine=typst` (and `studio_export_pdf` in some cases) failed
with `error: font fallback list must not be empty` unless the caller passed
`-V mainfont="..."`. The base now patches that template default to
`Libertinus Serif` (typst's own bundled default font) at build time, so PDF
export works out of the box. Base-affecting (`Dockerfile.base` RUN), rebuilds
`base-<hash>`. README gains a "Generating a PDF with pandoc + typst" section
with the working command and how to override the font via `-V mainfont`.
---
## v1.4.0 — 2026-07-11
Minor release. Headline: **PDF export works out of the box** — the base now
ships **`typst`** as the pandoc PDF engine (`pandoc --pdf-engine=typst`), so
`studio_export_pdf` / `pandoc -o out.pdf` no longer fail with "xelatex not
found". Also adds a **host SSH reachability check at shell startup**. Both are
base-affecting (`Dockerfile.base` apt+RUN for typst/xz-utils; `.bash_aliases`
for the SSH check is COPYd into the base), so the base rebuilds and both land in
`base-<hash>`. pi auto-resolves `latest` at build time (0.80.3 → 0.80.6);
mempalace stays pinned at 3.5.0 (current PyPI latest).
### Added
- **Host SSH reachability check at shell startup.** `~/.bash_aliases` (baked
into the image) now runs a one-time SSH probe on the first bash session of
each container. If the Mac host is not reachable (Remote Login disabled or
the `devbox_jump` key not yet authorized) it prints a clear warning with the
exact two steps to fix it, including the container's public key inline.
Subsequent shells in the same container skip the check (flag in `/tmp`,
cleared on recreate). Silent when SSH is working. Complements the existing
key-generation message in `setup-lan-access.sh` which only fires once at key
creation time and can easily be missed. Commit `4563b4d`.
- **`typst` — lightweight PDF engine for pandoc (Markdown→PDF).** `pandoc` has
shipped in the base since v1.0.0 but as a front-end only — with no PDF
back-end installed, `studio_export_pdf` / `pandoc -o out.pdf` failed with
"xelatex not found". The base now installs `typst`, a single ~30 MB static
Rust binary (no LaTeX), used via `pandoc --pdf-engine=typst`. Chosen over a
~600 MB TeX Live install; a fuller TeX Live remains the higher-fidelity
fallback for anyone needing LaTeX-exact output (install on demand). Also adds
`xz-utils` to the apt layer (typst ships a `.tar.xz` asset that `tar` needs
`xz` to extract). Installed with the standard `latest` GitHub-release idiom;
pin with `--build-arg TYPST_VERSION=vX.Y.Z`. This lands in `base-<hash>`
(Dockerfile.base changed). Supersedes the previously-planned
`:latest-studio-tex` variant — typst is small enough to ship in BASE, so no
separate TeX variant is needed. See `pi-devbox-roadmap`.
---
## v1.3.0 — 2026-07-02
Minor release. Headline: **shared/external MemPalace** — the `mempalace.ts`
bridge can now point at one MemPalace HTTP server (`MEMPALACE_REMOTE_URL`,
optional `MEMPALACE_REMOTE_TOKEN`) shared across containers/harnesses instead of
a per-container local palace; ships `docker-compose.mempalace.yml` for the
server. Also ships the **`nano` + `micro`** non-modal editors and a **CI
workflow-lint layer** (Gitea-accurate sh-vs-bash guard + actionlint/shellcheck),
with the `docker-publish.yml` bash-defaults and `promote-base-latest` shell
fixes. pi stays `0.80.3`; the base image rebuilds (the mempalace-toolkit ref
advanced and `Dockerfile.base` gained nano/micro), so the new bridge and editors
land in `base-<hash>`.
### Added
- **Share one MemPalace across containers via `MEMPALACE_REMOTE_URL`.** The
`mempalace.ts` bridge (from `mempalace-toolkit`) can now connect to a shared
MemPalace over HTTP instead of spawning a per-container local server: set
`MEMPALACE_REMOTE_URL=http://<host>:8765/mcp` (optionally
`MEMPALACE_REMOTE_TOKEN`) in `.env` and no local `mempalace-mcp` is spawned.
A new `docker-compose.mempalace.yml` stands up such a shared server
(`mempalace-mcp --transport http`). Leaving the URL unset keeps the default
local-per-container palace. See `.env.example`. (The HTTP transport is
unauthenticated — keep it on a trusted network or behind a reverse proxy.)
- **Two non-modal terminal editors alongside `nvim`: `nano` and `micro`.**
The image previously shipped only `nvim` (with `EDITOR=nvim`), a modal
vi-style editor. Not everyone is comfortable with vi keybindings, so both
a classic and a modern non-modal option now ship:
- **`nano`** (apt) — ~2.8 MB installed. Its dependencies (`libc6`,
`libncursesw6`, `libtinfo6`) are already present via `nvim`/`less`/`htop`/
`tmux`, so it pulls in **no extra packages**. On-screen shortcut hints
(`^O` write, `^X` exit) make it the lowest-friction fallback.
- **`micro`** — ~12 MB, a single static Go binary installed from GitHub
releases (same pattern as `bat`/`eza`/`zoxide`). 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. Pin with
`--build-arg MICRO_VERSION=vX.Y.Z`; defaults to `latest`.
Combined footprint is ~15 MB (<0.5% of the ~3.2 GB image). **`EDITOR`
stays `nvim`** — the new editors are opt-in via `export EDITOR=micro`
(or `nano`) and/or `git config --global core.editor micro`.
Note: micro's upstream repo moved `zyedidia/micro``micro-editor/micro`;
the Dockerfile uses the canonical URL because the old org's
`/releases/latest` redirect lands on another `/latest` URL (the org
rename), which would defeat the tag-parsing `latest`-resolution idiom.
These are base-image additions, so they only land once the `base-<hash>`
rebuilds (this file changed, so the next build picks them up).
### Added (CI)
- **Workflow lint (`.gitea/workflows/lint.yml`) running on every push and PR.**
Two complementary checks, so CI-workflow bugs are caught before an expensive
build runs:
- **`scripts/check-workflow-shell.sh`** — a Gitea-accurate guard that fails
if any `run:` step doesn't resolve to `bash` under Gitea's real defaults.
This catches the exact recurrence class (omit `shell:`, use bash syntax),
which **actionlint alone does not** — actionlint models GitHub Actions
(default shell = bash) and so assumes a shell-less step is bash, whereas
Gitea's default is `sh`/dash.
- **`actionlint` + `shellcheck`** — catches explicit `shell: sh` + bash
syntax (SC3040 etc.), expression errors, and general workflow mistakes.
Style-only shellcheck codes are excluded; the SC3xxx "wrong shell" family
is kept.
### Changed (CI)
- **Workflow-level `defaults: run: shell: bash` in `docker-publish.yml`.**
Gitea Actions defaults each `run:` step to `sh` (dash), so every bash-syntax
step had to individually remember `shell: bash` — a discipline requirement
that failed twice (ed49b8d, b7197e8). Setting the default workflow-wide
eliminates the whole class. All pre-existing dash steps use only POSIX
syntax, so bash (a superset) runs them unchanged.
### Fixed (CI)
- **`promote-base-latest` now sets `shell: bash` on the base-latest re-tag
step.** The `b7197e8` fix (v1.2.4) moved the digest-compare into that step
with `set -euo pipefail`, but Gitea Actions' default step shell is `sh`
(dash), which rejects `-o pipefail` (`Illegal option -o pipefail`) and aborts
the step before the `crane copy` runs. On the v1.2.4 release (run 418) this
left `base-latest` un-promoted, still pointing at the v1.2.3 base — the four
consumer tags (`v1.2.4`, `latest`, `v1.2.4-studio`, `latest-studio`) were
unaffected because they `FROM` the exact `base-<hash>`, not `base-latest`.
Same footgun as `ed49b8d` (`resolve-versions needs shell: bash`).
---
## v1.2.4 — 2026-06-29
Patch release. Headline: **pi `0.80.2` → `0.80.3`** (npm `latest`). Also ships a
global gitignore baked into the image, secrets-via-`env_file`-only compose
hardening, and a CI fix so `promote-base-latest` re-points `base-latest`
reliably after a dry-run-first release. The mempalace pin stays `3.5.0`. The
base image rebuilds because `Dockerfile.base` changed (the gitignore seed +
`entrypoint-user.sh` wiring).
### 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.
### Changed
- **Secrets are now delivered to the container via `env_file: .env` only; the
`environment:` block no longer re-declares `GITEA_ACCESS_TOKEN`,
`GITEA_HOST`, or `GITHUB_PERSONAL_ACCESS_TOKEN`.** An `environment:` entry
both overrides `env_file:` and is interpolated from the host shell, so a
stale shell export (e.g. one auto-loaded by an opencode/dotenv hook) would
silently shadow the value in your `.env` — an updated token in `.env` never
reached the container. Delivering secrets via `env_file` only decouples the
container from whatever the host shell happens to export. No action needed:
`.env.example` already documents every supported variable. Affects
`docker-compose.yml` and the README “basic shape” snippet.
### Fixed (CI)
- **`promote-base-latest` now re-points `base-latest` reliably after a
dry-run-first release.** The job's gate previously required
`need_build == 'true'`, on the assumption that `need_build == false`
implied `base-latest` was already current. That assumption breaks when a
`workflow_dispatch` dry-run (`promote_latest=false`) pre-builds and pushes
`base-<hash>` first: the subsequent tag run then sees `need_build == false`
(probe hit) and **skipped** promotion, leaving `base-latest` pointing at the
*previous* base. (Observed 2026-06-27 releasing v1.2.3 via dry-run-then-tag
`base-latest` ended up one base behind, lacking the mempalace self-heal.)
Now the gate runs on every tag release (or `promote_latest=true` 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>`, so genuine cache-hit releases stay a no-op while
stale aliases get corrected. No image-content change; base hash unaffected.
---
## v1.2.3 — 2026-06-27
Patch release. Headline: **mempalace-mcp now self-heals** instead of latching
`available=false` permanently after a slow cold-open. Also folds in the `yq`
and mempalace-skill changes that were sitting unreleased. **No pi/mempalace
version change** — pi npm `latest` is still `0.80.2` (= v1.2.2) and the
mempalace pin stays `3.5.0`; the base image rebuilds purely because the
`mempalace-toolkit` ref advances to pick up the self-heal extension.
### Fixed
- **mempalace-mcp self-heal — no more permanent `available=false` latch.**
The `mempalace.ts` pi extension (from `mempalace-toolkit`, bumped to
[`e12b624`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/commit/e12b624))
previously tripped its per-request timeout on a slow virtiofs cold-open of
the palace, killed the child, and set `available=false` **forever** (no
respawn) — a pi restart was the only recovery.
- **Bounded respawn with capped exponential backoff** via `ensureAlive()`
(`MEMPALACE_MCP_MAX_RESPAWNS=2`, `MEMPALACE_MCP_RESPAWN_BACKOFF_MS=1000`;
set max to `0` to disable). Both `execute()` and initial startup route
through it. The respawn budget **resets on any successful JSON-RPC
response** (`onStdout`), so a healthy session can't slowly exhaust it.
- **Scoped init timeout** raised `120000 → 300000` ms (`MEMPALACE_MCP_INIT_TIMEOUT_MS`),
affecting **init only** — the per-call timeout stays `60000`
(`MEMPALACE_MCP_TIMEOUT_MS`) — so a genuine cold HNSW deserialize isn't
killed mid-open.
- **Concurrency hardening:** a generation counter prevents a late-exiting
killed process from clobbering a fresh respawn, and an explicit `healthy`
flag replaces the racy `proc != null` check.
- Note: the build-time `smoke-test.sh` verifies the extension is present and
deployed but does **not** exercise respawn behaviour — first live
validation is on a running container.
- **`yq` is now mikefarah's Go yq, not Debian's Python `yq`.** The base image
previously apt-installed `yq`, which on Debian/Ubuntu is the unrelated
kislyuk/`yq` (a jq wrapper, v3.x) — incompatible with the mikefarah v4 syntax
the `cloud-init` repo's `provision.sh`/`deploy.sh` expect. Dropped the apt
package and install the mikefarah binary instead (multi-arch amd64/arm64,
following the repo's `latest` convention like `tealdeer`/`uv`; pin a tag
with `--build-arg YQ_VERSION=vX.Y.Z`). The build-time `smoke-test.sh` gate
asserts `yq --version` reports `mikefarah` **and** major **v4**, so both a
regression to the Python package and a surprise future yq v5 fail CI.
### Changed
- **Baked `mempalace` skill now teaches temporal grounding.** Added a
*Temporal grounding* rule to the image-baked
`skills/mempalace/SKILL.md` (Phase 1 wake-up + a matching anti-pattern):
before using relative time terms ("yesterday", "last week"), establish the
current date/time and compute the delta against the actual diary/drawer
timestamp. Explicitly calls out that a **container recreate or fresh session
is not a day boundary** — pi-devbox restarts several times a day, so two
entries minutes apart can straddle a recreate. Fixes agents mislabelling
same-day sessions as "yesterday".
---
## v1.2.2 — 2026-06-24
Patch release: pick up **pi `0.80.2`** (npm `latest`) and **mempalace `3.5.0`**,
+2 -1
View File
@@ -156,4 +156,5 @@ Optional volumes for MemPalace (commented out by default — uncomment in `docke
## License
MIT (the image; pi and the bundled tools each carry their own licenses).
MIT (the image; pi and the bundled tools each carry their own licenses). See
`LICENSE` and `THIRD_PARTY.md` in the [source repo](https://gitea.jordbo.se/joakimp/pi-devbox).
+209 -14
View File
@@ -14,7 +14,7 @@
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-06-09 (v1.0.0 — decoupled from opencode-devbox)
# BASE_REBUILD_DATE: 2026-07-13 (Unreleased — agent-browser CLI + Playwright Chromium for headless browser automation; prior: typst PDF engine + xz-utils + pandoc typst-template default-font patch)
#
# ── Lineage note ─────────────────────────────────────────────────────
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
@@ -46,16 +46,43 @@ ENV DEBIAN_FRONTEND=noninteractive
# Additions vs the upstream opencode-devbox base (2026-06-09):
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
# preview/export pipelines and broadly useful for any
# agent-driven document workflow. ~200 MB.
# agent-driven document workflow. ~200 MB. NOTE: pandoc is
# only the front-end — PDF output needs a back-end engine.
# We ship `typst` (installed further down) as the
# lightweight default engine (`pandoc --pdf-engine=typst`)
# instead of a ~600 MB TeX Live install.
# xz-utils — `xz` decompressor. tar shells out to it for `.tar.xz`
# assets (typst ships .tar.xz). ~0.5 MB. Also generally
# useful for extracting xz-compressed archives.
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
# See the bundled `dot-watch` helper for live .dot -> PNG
# re-render (handy with pi-studio's image preview).
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
# yq — YAML-aware companion to jq.
# (yq is NOT apt-installed: Debian's `yq` is the unrelated Python tool;
# mikefarah's Go yq is installed as a pinned binary further down.)
# socat — TCP relay. Powers `studio-expose`, which bridges
# pi-studio's container-loopback server to the container's
# external interface so a published port can reach it.
# ~1 MB; generally useful for any port-forwarding need.
# nano — small, non-modal terminal editor for users who don't want
# a vi-based editor. ~2.8 MB installed; its deps (libc6,
# libncursesw6, libtinfo6) are already pulled in by nvim/less/
# htop/tmux, so it adds no extra packages. Companion to nvim
# and the `micro` binary installed further down. EDITOR stays
# nvim; users opt in via `export EDITOR=nano`.
# kitty-terminfo — terminfo entry for the kitty terminal (TERM=xterm-kitty).
# ~77 KB, terminfo file only (no kitty binary). Without it,
# ncurses apps fall back and Neovim can't reliably detect
# true-colour from kitty over ssh; installing it makes
# TERM=xterm-kitty understood. Pairs with the system-wide
# Neovim termguicolors default (etc/xdg/nvim/sysinit.vim).
# ncurses-term — broad terminfo bundle (wezterm, alacritty, foot, st, the
# base `ghostty` entry, and many more) so SSHing in from a
# modern emulator resolves its TERM instead of degrading to a
# dumb fallback. xterm-kitty is NOT in it (hence kitty-terminfo
# above); TERM=xterm-ghostty is compiled from an alias further
# down (ncurses ships `ghostty`, not `xterm-ghostty`). iTerm2
# defaults to xterm-256color (ncurses-base), so needs nothing.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
@@ -66,7 +93,6 @@ RUN apt-get update && \
openssh-client \
gnupg \
jq \
yq \
ripgrep \
fd-find \
tree \
@@ -89,9 +115,13 @@ RUN apt-get update && \
python3-pip \
python3-venv \
pandoc \
xz-utils \
graphviz \
imagemagick \
socat \
nano \
kitty-terminfo \
ncurses-term \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -231,6 +261,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
ARG BAT_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
@@ -289,15 +346,21 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
# time to shave ~300 MB.
#
# Stall protection (fixed 2026-06-13): mempalace-mcp is launched by the
# `mempalace.ts` pi extension from mempalace-toolkit (cloned below). That
# extension now applies a per-REQUEST timeout in its JSON-RPC client and
# kills the child on stall, so a virtiofs cold-open of chroma.sqlite3 /
# HNSW load can no longer hang the pi TUI uninterruptibly. Tunables:
# Stall protection (fixed 2026-06-13; self-heal added 2026-06-25):
# mempalace-mcp is launched by the `mempalace.ts` pi extension from
# mempalace-toolkit (cloned below). That extension applies a per-REQUEST
# timeout in its JSON-RPC client and kills the child on stall, so a virtiofs
# cold-open of chroma.sqlite3 / HNSW load can no longer hang the pi TUI
# uninterruptibly. A stall-kill is no longer a permanent latch either: the
# next tool call respawns the server with capped exponential backoff (the
# budget resets on any successful response). Tunables:
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
# (default 120000); 0 disables. A standalone stdio-watchdog shim is NOT
# needed — the extension already owns request/response correlation. See
# CHANGELOG.md "Unreleased > Fixed".
# (default 300000 — generous so a genuine first cold-open isn't killed),
# MEMPALACE_MCP_MAX_RESPAWNS (default 2; 0 disables self-heal),
# MEMPALACE_MCP_RESPAWN_BACKOFF_MS (default 1000); timeouts of 0 disable.
# Defaults live in the extension, so no ENV is needed here. A standalone
# stdio-watchdog shim is NOT needed — the extension already owns
# request/response correlation. See CHANGELOG.md "Unreleased > Fixed".
ARG INSTALL_MEMPALACE=true
# Pin to a known-good version. Bump deliberately, not implicitly: an
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
@@ -381,6 +444,11 @@ ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
# Advertise 24-bit colour so colour-aware tools (Neovim's own auto-detect, bat,
# delta, ...) use true colour instead of a 256-colour fallback. Safe for the
# modern terminals this devbox targets; override by exporting `COLORTERM=`
# (empty) from a terminal that lacks true-colour support.
ENV COLORTERM=truecolor
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# ── Node.js (required for pi + MCP servers + tldr) ──
@@ -389,6 +457,58 @@ RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesour
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# ── agent-browser — headless browser automation for the agent ────────
# Gives the agent a real browser it can drive (open/click/fill/eval/
# screenshot) so front-end work involving live DOM or WebGL can be VERIFIED
# rather than guessed at. The `agent-browser` skill (shipped from the
# skillset repo, not this image) documents the CLI; without this block that
# skill is a no-op because the binary isn't present. Verified end-to-end
# 2026-07-13: drives the baked Chromium headless (open + screenshot + eval
# into a WebGL SPA) — doctor's launch test passes in ~0.5s.
#
# TWO pieces, because agent-browser is a standalone Rust CLI that ships NO
# browser of its own — it only drives one you provide:
# 1. the CLI itself (npm; ~70 MB of prebuilt native binaries), and
# 2. a Chromium, which we fetch via Playwright.
#
# Why Playwright fetches the browser (and NOT `agent-browser install`):
# agent-browser's own installer drops Chrome under ~/.agent-browser/browsers
# — inside /home/${USER_NAME}, which is a NAMED VOLUME at runtime, so a
# build-time download would be SHADOWED (invisible) once the volume mounts.
# Playwright honours PLAYWRIGHT_BROWSERS_PATH, so we place the browser under
# /usr/local/share (never shadowed) and hand agent-browser a STABLE symlink
# via AGENT_BROWSER_EXECUTABLE_PATH — the symlink insulates the ENV from
# Playwright's per-version, per-ARCH browser directory (`chrome-linux` on arm64,
# `chrome-linux64` on amd64 — Chrome-for-Testing), so we `find` the `chrome`
# binary rather than hardcode the path; the headless-shell binary is named
# `chrome-headless-shell`, so `-name chrome` skips it.
#
# `playwright install --with-deps chromium` also apt-installs Chromium's
# runtime libs; verified to resolve correctly on Debian trixie (exit 0 — the
# t64 library renames are handled by Playwright's dep list). Build runs as
# root, so the apt step works. NPM_CONFIG_PREFIX=/usr keeps both CLIs on /usr
# so they survive the ~/.pi/npm-global volume mount (same trick the variant
# uses for pi). After fetching, we DROP Playwright's `chromium_headless_shell-*`
# build — agent-browser drives the full chrome (verified, incl. headless), so the
# headless shell is dead weight — and clean the apt/npm caches, trimming the
# layer to ~625 MB (Chromium) from ~960 MB. Still the bulk of the base's size,
# and the one real tradeoff of shipping this to every variant.
ARG AGENT_BROWSER_VERSION=latest
ARG PLAYWRIGHT_VERSION=latest
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
RUN NPM_CONFIG_PREFIX=/usr npm install -g \
"agent-browser@${AGENT_BROWSER_VERSION}" \
"playwright@${PLAYWRIGHT_VERSION}" && \
playwright install --with-deps chromium && \
CHROME="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*/chromium-*/*' | head -n1)" && \
[ -n "$CHROME" ] && ln -sf "$CHROME" /usr/local/bin/agent-chrome && \
agent-browser --version && \
test -x "$(readlink -f /usr/local/bin/agent-chrome)" && \
rm -rf "${PLAYWRIGHT_BROWSERS_PATH}"/chromium_headless_shell-* && \
npm cache clean --force && \
rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/agent-chrome
# ── tldr (tealdeer) — community-maintained command examples ──────────
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
@@ -404,6 +524,61 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
chmod +x /usr/local/bin/tldr && \
tldr --version
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
# PDF export (studio_export_pdf) and pandoc invocations use it via
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
# here — install on demand or in a future variant).
#
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
# that extracts to typst-<arch>-unknown-linux-musl/typst. Pin a specific
# tag with --build-arg TYPST_VERSION=vX.Y.Z.
#
# We also patch pandoc's bundled typst template
# (/usr/share/pandoc/data/templates/template.typst): its conf() defaults the
# document font to an empty tuple (`font: ()`), so a naked
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
# (typst's own bundled default font) so PDF export works out-of-the-box.
ARG TYPST_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TYPST_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing typst ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/download/v${V}/typst-${ARCH}-unknown-linux-musl.tar.xz" | tar -xJ -C /tmp && \
install /tmp/typst-${ARCH}-unknown-linux-musl/typst /usr/local/bin/typst && \
rm -rf /tmp/typst-${ARCH}-unknown-linux-musl && \
typst --version && \
sed -i 's/^ font: (),$/ font: ("Libertinus Serif",),/' /usr/share/pandoc/data/templates/template.typst && \
grep -q 'font: ("Libertinus Serif",),' /usr/share/pandoc/data/templates/template.typst
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and
# version line, e.g. 3.x). The cloud-init repo's deploy.sh/provision.sh
# require mikefarah yq v4 (the unrelated Debian python yq is v3.x). Follows
# the repo's `latest` convention (like tealdeer/uv/etc.); the smoke test pins
# the contract to major v4, so a future yq v5 fails CI instead of silently
# breaking provision.sh. Pin a specific tag with --build-arg YQ_VERSION=vX.Y.Z.
ARG YQ_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${YQ_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
[ -n "$V" ] && \
echo "Installing mikefarah yq ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
chmod +x /usr/local/bin/yq && \
yq --version
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
@@ -451,8 +626,26 @@ ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
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
# ── Editor defaults: system-wide Neovim true-colour ──────────────────
# /etc/xdg/nvim/sysinit.vim is Neovim's system vimrc: it loads for every user
# (before any personal ~/.config/nvim) and can still be overridden per-user.
# Enables termguicolors so the default theme renders in 24-bit colour instead
# of a muddy 256-colour fallback. Pairs with kitty-terminfo (installed above).
COPY rootfs/etc/xdg/nvim/sysinit.vim /etc/xdg/nvim/sysinit.vim
# ── Terminal support: xterm-ghostty terminfo alias ──────────────────
# ncurses-term (installed above) covers wezterm/alacritty/foot/st and the base
# `ghostty` entry, but Ghostty connects with TERM=xterm-ghostty, for which no
# distro packages an entry. Ship a thin alias (use=ghostty) and compile it into
# the system terminfo db with `tic -x`, so it inherits the maintained ghostty
# capability set. The `infocmp` check fails the build if the entry didn't land.
COPY rootfs/usr/local/share/terminfo-src/ghostty.terminfo /usr/local/share/terminfo-src/ghostty.terminfo
RUN tic -x -o /usr/share/terminfo /usr/local/share/terminfo-src/ghostty.terminfo && \
infocmp -x xterm-ghostty >/dev/null
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
@@ -463,11 +656,13 @@ COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
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/studio-expose \
/usr/local/bin/dot-watch \
/usr/local/bin/pi-devbox-version \
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
# Start as root — entrypoint adjusts UID/GID then drops to developer
+6 -1
View File
@@ -158,6 +158,10 @@ RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
ARG INSTALL_STUDIO=false
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
ARG PI_STUDIO_REF=main
# PI_STUDIO_VERSION is the human-readable tag (e.g. v0.9.36) that PI_STUDIO_REF
# was resolved from; recorded as a label below for at-a-glance identification.
# Only meaningful for the studio variant (default `none` otherwise).
ARG PI_STUDIO_VERSION=none
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
set -e; \
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
@@ -225,7 +229,8 @@ LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}"
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}"
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
# of each /opt clone and the live `pi --version` — not merely the intended
+60
View File
@@ -0,0 +1,60 @@
# Ideas & backlog
A living list of potential improvements for pi-devbox that are **not yet
scheduled**. This is intentionally lightweight — a place to park ideas so they
aren't lost between sessions. When an item ships, describe it in
[`CHANGELOG.md`](CHANGELOG.md) and remove it from here.
Rough effort tags: 🟢 small · 🟡 medium · 🔴 large. Status: `idea` (unvetted) ·
`planned` (agreed, not started).
---
## Supply-chain hardening
- 🟡 `planned`**Pin CI actions to commit SHAs.** The workflows use floating
major tags (`actions/checkout@v4`, `docker/build-push-action@v7`,
`docker/setup-buildx-action@v4`, `docker/login-action@v3`,
`docker/setup-qemu-action@v3`). This is inconsistent with the project's own
philosophy of SHA-pinning *content* refs (pi, pi-studio, pi-fork, …) to defeat
floating refs. Pin each action to a SHA with a trailing `# vX.Y.Z` comment.
Pairs naturally with the renovate item below to keep the pins fresh.
- 🟡 `planned`**Vulnerability scanning in CI.** No CVE scan runs on the
published images today. Add a `trivy image` (or grype) job to
`docker-publish.yml` after `smoke`. Start non-blocking (report only), then
tighten to fail on `HIGH`/`CRITICAL` with an available fix.
- 🟢🟡 `planned` — **Standardize build provenance → buildx SBOM + attestations.**
The image already carries hand-rolled provenance (OCI labels +
`build-manifest`). `docker/build-push-action` can emit a standard SBOM and
SLSA provenance attestation nearly for free (`provenance: mode=max`,
`sbom: true`). Makes provenance machine-consumable and pairs well with the
trivy item (scan the SBOM).
## Dockerfile hardening
- 🟡 `idea`**Address hadolint DL4006 properly.** Currently ignored in
`.hadolint.yaml`. The clean fix is `SHELL ["/bin/bash", "-o", "pipefail",
"-c"]` so piped `RUN`s fail on the first non-zero stage. This changes the
default `RUN` shell from `sh` to `bash` for all subsequent layers, so it is
base-affecting and needs a careful pass over existing `RUN`s before removing
the ignore.
## Developer experience
- 🟢 `idea`**`Makefile`/`justfile` for local iteration.** Reproducing a CI
build locally means hand-assembling many `--build-arg`s. Thin targets
(`make build-base`, `make build-variant`, `make smoke`, `make lint`) would
make local testing painless and document the canonical invocations.
- 🟡 `idea`**Dependency-update automation (renovate).** With CI actions
SHA-pinned (above), a `renovate.json` keeps those pins — plus the pinned tool
versions (`ACTIONLINT_VERSION`, `HADOLINT_VERSION`, gosu, etc.) — current via
automated PRs. Requires a renovate runner against the Gitea instance.
## Housekeeping
- 🟢 `idea`**Registry retention for `base-<hash>` tags.** The base-hash
caching scheme accumulates `base-<hash>` tags over time. Confirm whether the
registry prunes old ones, and add a retention/cleanup step if not.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joakim Persson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82 -11
View File
@@ -29,13 +29,18 @@ on the host.
- ChromaDB embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
The host-mounted palace at `~/.mempalace` is shared across the host and
this container so all your agents share one brain.
this container so all your agents share one brain. To instead share a palace
across *several* containers/harnesses, set `MEMPALACE_REMOTE_URL` to a shared
MemPalace HTTP endpoint (see `.env.example` and `docker-compose.mempalace.yml`);
the bridge then connects over HTTP and spawns no local server.
### Modern CLI tooling
| Tool | Purpose |
|---|---|
| `nvim` | Neovim text editor |
| `nvim` | Neovim text editor (modal / vi-style) |
| `nano` | Small non-modal editor (on-screen shortcut hints) |
| `micro` | Modern non-modal editor (Ctrl+S/Ctrl+Q keys, mouse, syntax highlighting) |
| `tmux` | Terminal multiplexer (configured for 0-indexed sessions) |
| `ripgrep`, `fd` | Fast file content / filename search |
| `fzf` | Fuzzy finder |
@@ -49,6 +54,17 @@ this container so all your agents share one brain.
| `gosu` | Privilege de-escalation in entrypoint |
| `htop`, `tree`, `less` | Inspection utilities |
The default `$EDITOR` is `nvim`. Three editors ship so you can pick your
comfort level — if you'd rather not use a vi-style editor, `nano` and `micro`
are both non-modal. Set your preference with `export EDITOR=micro` (or `nano`)
in your shell profile, and/or `git config --global core.editor micro`.
Neovim ships with a system-wide default (`/etc/xdg/nvim/sysinit.vim`) that turns
on `termguicolors`, so its colours render in 24-bit instead of a muddy
256-colour fallback over ssh/kitty. The `kitty-terminfo` entry is also bundled
so `TERM=xterm-kitty` is understood. Override either in your own
`~/.config/nvim`.
### Document and image tooling
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
@@ -139,8 +155,10 @@ Currently published:
Planned for an upcoming minor release:
- `joakimp/pi-devbox:latest-studio-tex``-studio` plus `texlive-xetex`
for PDF export from Studio. Adds ~600 MB on top of `-studio`.
- *(shipped in Unreleased/base)* **PDF export from Studio/pandoc** now works:
the base image ships **`typst`** as the PDF engine (`pandoc --pdf-engine=typst`),
a single ~30 MB static binary — no separate `-tex` variant needed.
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
## Using pi-studio (`-studio` variant)
@@ -275,9 +293,30 @@ Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B):
> until step 2 runs. If the browser can't connect, verify Studio is up
> (`/studio --status`) and the bridge is running (`ps aux | grep socat`).
> PDF export (`/studio-pdf`, `studio_export_pdf`) needs a LaTeX engine,
> which is **not** in `-studio` (only the planned `-studio-tex`). HTML
> export, KaTeX, Mermaid, and all REPL features work without it.
> PDF export (`/studio-pdf`, `studio_export_pdf`) uses **`typst`**, shipped in
> the base image as the pandoc PDF engine (`pandoc --pdf-engine=typst`). For
> LaTeX-exact output you can install `texlive-xetex` on demand as a heavier
> fallback. HTML export, KaTeX, Mermaid, and all REPL features work regardless.
### Generating a PDF with pandoc + typst
The base ships `pandoc` (front-end) and `typst` (PDF engine), so Markdown → PDF
works out of the box:
```bash
pandoc doc.md --pdf-engine=typst -o doc.pdf
```
The base patches pandoc's bundled typst template so it defaults to the
**Libertinus Serif** font. Without that patch a naked `--pdf-engine=typst`
fails with `error: font fallback list must not be empty`, because the upstream
template leaves the font unset. To pick a different face, pass one of the fonts
typst can see (`typst fonts` lists them — DejaVu Serif/Sans/Mono, Libertinus
Serif, New Computer Modern):
```bash
pandoc doc.md --pdf-engine=typst -V mainfont="New Computer Modern" -o doc.pdf
```
### Graphviz diagrams in Studio: `dot-watch`
@@ -319,9 +358,10 @@ services:
environment:
- TERM=xterm-256color
# - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
# Secrets (GITEA_*, GITHUB_*, …) come from env_file: .env above — not
# duplicated here. An environment: entry overrides env_file and is
# interpolated from the host shell, so a stale shell export would
# silently shadow your .env. See .env.example for the full list.
volumes:
# Workspace: your host source tree
- ${WORKSPACE_PATH:-.}:/workspace
@@ -653,6 +693,34 @@ truth** — the actual checked-out commit of each `/opt` clone and the live
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
```
Inside a running container, `pi-devbox-version` wraps that manifest into a
human-readable summary — no need to remember the file path or pipe it
through `jq` yourself:
```console
$ pi-devbox-version
pi-devbox v1.5.0
built: 2026-07-13T17:53:16Z (source d68674d11e06)
pi: 0.80.6
components:
pi-toolkit: 9a8f6faeaa08
pi-extensions: 61c98e004e3d
pi-fork: 4a09af4ef527
pi-observational-memory: 27a5195eaf90
mempalace-toolkit: 96699f2a1781
pi-studio: 2ef38ef31cea
```
It also flags **live drift** — if `pi --version` no longer matches what was
baked at build time (e.g. something on a persisted volume shadowed the
image's binary), the `pi:` line calls that out instead of silently trusting
the manifest. `--json` dumps the raw manifest for scripting; `--quiet` gives
a one-line `release_tag (source_revision)` form. It also prints once,
automatically, at container start (from `entrypoint-user.sh`, before the
rest of the setup output) — so you see which build you're in without
asking. Exits 1 with a short notice on images built before this file
existed, rather than failing silently.
## Troubleshooting
### Image grew unexpectedly
@@ -743,4 +811,7 @@ The pi coding-agent itself is [@earendil-works/pi-coding-agent](https://www.npmj
## License
MIT
MIT — see [`LICENSE`](LICENSE). This covers the repository's own contents
(Dockerfiles, entrypoint scripts, `rootfs/` seeds, CI, docs). The published
images bundle third-party software under their own licenses; see
[`THIRD_PARTY.md`](THIRD_PARTY.md).
+46
View File
@@ -0,0 +1,46 @@
# Third-party notices
pi-devbox is distributed under the MIT License (see [`LICENSE`](LICENSE)), which
covers **this repository's own contents** — the Dockerfiles, entrypoint scripts,
`rootfs/` seeds, CI workflows, and docs.
The **published container images** (`joakimp/pi-devbox:*`) additionally *bundle*
third-party software, each of which remains under its own license. This file is
a good-faith summary; the authoritative sources are the upstream projects and,
for OS packages, the per-package copyright files inside the image at
`/usr/share/doc/<package>/copyright`.
## pi and its extensions (installed in the variant layer)
| Component | Upstream | License |
| --- | --- | --- |
| pi (`@earendil-works/pi-coding-agent`) | npm | MIT |
| pi-fork | github.com/elpapi42/pi-fork | MIT |
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
## Tooling baked into the base image
| Component | Upstream | License (best effort) |
| --- | --- | --- |
| gosu | github.com/tianon/gosu | Apache-2.0 |
| Node.js | nodejs.org | MIT (bundles components under their own licenses) |
| uv | github.com/astral-sh/uv | Apache-2.0 OR MIT |
| Neovim | neovim.io | Apache-2.0 + Vim license |
| Pandoc | pandoc.org | GPL-2.0-or-later |
| Typst | github.com/typst/typst | Apache-2.0 |
| ripgrep / fd / micro / tealdeer / yq (mikefarah) | respective repos | MIT / Apache-2.0 / Unlicense (varies) |
## Base OS
The image is built `FROM` a Debian base and installs packages via `apt`. Debian
and its packages are distributed under their respective licenses (GPL, LGPL,
MIT, BSD, and others). See each package's copyright file in the image under
`/usr/share/doc/<package>/copyright`.
---
*Licenses marked "best effort" are widely known but were not each verified at
the exact bundled version; consult the upstream project for authoritative
terms. Corrections welcome.*
+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/pi-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
+11 -4
View File
@@ -31,9 +31,13 @@ services:
- .env
environment:
- TERM=xterm-256color
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
# Secrets (GITEA_*, GITHUB_*, and any others) are delivered to the
# container via `env_file: .env` above — do NOT duplicate them here.
# An `environment:` entry overrides env_file AND is interpolated from
# the host shell, so a stale shell export (e.g. one auto-loaded by a
# dotenv hook) would silently shadow the value in your .env. Keeping
# secrets env_file-only decouples the container from the host shell.
# See .env.example for the full list of supported variables.
volumes:
# Host workspace — mount your project here
- ${WORKSPACE_PATH:-.}:/workspace
@@ -72,7 +76,10 @@ services:
# Persist uv data (Python installs, tool installs)
- devbox-uv:/home/developer/.local/share/uv
# Optional: persist MemPalace data (conversation memory, knowledge graph)
# Optional: persist MemPalace data (conversation memory, knowledge graph).
# Applies to the LOCAL palace only (the default). In EXTERNAL mode
# (MEMPALACE_REMOTE_URL set in .env) the shared server owns the data, so
# this volume is irrelevant.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB)
+15 -1
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Startup banner: which pi-devbox build is this? ─────────────────
# Printed FIRST, before the setup noise below, so it's the first thing
# visible when the container starts (CMD is `bash -l`, tty:true in compose,
# so this reaches the same stream as the interactive shell the user lands
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
# with a short stderr notice on images built before it existed.
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
# ── SSH ControlMaster socket dir ────────────────────────────────
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
@@ -33,7 +41,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
@@ -91,6 +99,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
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
+18
View File
@@ -0,0 +1,18 @@
" pi-devbox — system-wide Neovim defaults.
"
" This is Neovim's *system vimrc*: it loads for every user before any personal
" ~/.config/nvim, and personal configs can still override it.
"
" Enable 24-bit ("true") colour. Without it, Neovim's default theme is squeezed
" into a 256-colour palette where strings/comments become a muddy, low-contrast
" dark colour — a common complaint over ssh/kitty where COLORTERM often isn't
" propagated into the container. Modern terminals (kitty, WezTerm, iTerm2,
" Alacritty, ...) all support true colour; the bundled kitty-terminfo also lets
" Neovim auto-detect it, but forcing it here guarantees readable colour
" regardless of how the terminal type / COLORTERM reach the container.
"
" Opt out for a session: :set notermguicolors
" Override permanently: set your own value in ~/.config/nvim/init.lua
if has('termguicolors')
set termguicolors
endif
+32
View File
@@ -54,6 +54,38 @@ alias gs='git status'
alias gd='git diff'
alias gl='git log --oneline --graph --decorate -20'
# ── Host SSH reachability check (once per container lifetime) ───────────────
# Warns at first shell startup if the Mac host is not reachable via SSH.
# Only runs inside a container, only if the jump key exists, and only once
# per container lifetime (/tmp flag is cleared on recreate).
_devbox_check_host_ssh() {
[ -f "/.dockerenv" ] || return 0
local ssh_cfg="$HOME/.ssh-local/config"
[ -f "$ssh_cfg" ] || return 0
local key_pub="$HOME/.ssh-local/devbox_jump_ed25519.pub"
[ -f "$key_pub" ] || return 0
local flag="/tmp/.devbox_host_ssh_ok"
[ -f "$flag" ] && return 0
if ssh -F "$ssh_cfg" \
-o BatchMode=yes \
-o ConnectTimeout=2 \
-o StrictHostKeyChecking=accept-new \
mac true 2>/dev/null; then
touch "$flag"
return 0
fi
local pub_key
pub_key=$(cat "$key_pub")
printf '\n\033[1;33m⚠ devbox: Mac host not reachable via SSH\033[0m\n'
printf ' Some tools use SSH to run commands on the Mac host.\n'
printf ' Fix (run both on the Mac):\n\n'
printf ' \033[1mStep 1\033[0m System Settings → General → Sharing → Remote Login → ON\n\n'
printf ' \033[1mStep 2\033[0m echo '"'"'%s'"'"' >> ~/.ssh/authorized_keys\n' "$pub_key"
printf '\n Then open a new shell in the container to verify.\n\n'
}
_devbox_check_host_ssh
unset -f _devbox_check_host_ssh
# ── LAN access via the host (dssh) ───────────────────────────────────
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
+14
View File
@@ -0,0 +1,14 @@
# 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
# AI/LLM tool local settings — machine-specific perms + credentials, never commit
**/.claude/settings.local.json
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# pi-devbox-version — show which pi-devbox image build is running.
#
# WHY THIS EXISTS
# The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json
# at `docker build` time (see Dockerfile.variant): the release tag, build date,
# source commit, live `pi --version` at build time, and the actual checked-out
# commit of every /opt component clone. That answers "what image am I running?"
# — but only if you know to go look for the file. This wraps it into one
# command, prints it human-first at container start (see entrypoint-user.sh),
# and stays available on demand for the rest of the session.
#
# USAGE
# pi-devbox-version human-readable summary (default)
# pi-devbox-version --json raw manifest JSON (for scripting)
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
#
# EXIT STATUS
# 0 on success. 1 if the manifest is missing (e.g. an image built before
# this file existed, or a non-pi-devbox base) — prints a short notice
# to stderr rather than failing silently.
set -euo pipefail
MANIFEST=/etc/pi-devbox/build-manifest.json
MODE="human"
case "${1:-}" in
--json) MODE="json" ;;
--quiet|-q) MODE="quiet" ;;
--help|-h)
sed -n '2,20p' "$0" | sed 's/^# \?//'
exit 0
;;
esac
if [ ! -f "$MANIFEST" ]; then
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2
cat "$MANIFEST"
exit 0
fi
if [ "$MODE" = "json" ]; then
cat "$MANIFEST"
exit 0
fi
release_tag=$(jq -r '.release_tag' "$MANIFEST")
build_date=$(jq -r '.build_date' "$MANIFEST")
source_rev=$(jq -r '.source_revision' "$MANIFEST")
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
if [ "$MODE" = "quiet" ]; then
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
exit 0
fi
# Live drift check: has `pi` been upgraded since this container was built?
# (image is immutable, but a volume-persisted ~/.pi could in theory shadow
# the baked binary — this stays honest rather than trusting the manifest
# blindly, same "ground truth over intent" spirit as how the manifest
# itself is generated in Dockerfile.variant.)
pi_version_live=""
if command -v pi >/dev/null 2>&1; then
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
fi
printf 'pi-devbox %s\n' "$release_tag"
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked"
else
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
fi
printf ' components:\n'
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
@@ -19,6 +19,18 @@ be discovered at runtime, never assumed. And interactive shell aliases
tool, so spell out the underlying command (e.g.
`ssh -F "$HOME/.ssh-local/config" mac …`).
## Browser automation is available (agent-browser)
This image bakes the **`agent-browser`** CLI plus a headless Chromium, so you can
drive a real browser — open pages, click/fill/`eval`, snapshot the DOM, take
screenshots — to **verify** front-end work (live DOM, WebGL, layout, popup
positioning) instead of guessing. Reach for it whenever a task involves a web UI
or checking how a page actually renders. `AGENT_BROWSER_EXECUTABLE_PATH` is
preset to the baked browser, so `agent-browser open <url>` works out of the box
(headless). Run `agent-browser skills get core --full` for the command set and
workflow patterns (always version-matched to the CLI); the `agent-browser` skill
under `~/.agents/skills/` mirrors it when the skillset is mounted.
## Session start: load the mempalace skill
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
@@ -43,6 +43,29 @@ Run these immediately when a session begins, before responding to the user:
Do NOT announce this to the user. Just do it silently to orient yourself.
### Temporal grounding — compute time deltas, don't guess
Diary entries and drawers carry real timestamps (`timestamp`, `created_at`).
Before describing *when* something happened — "yesterday", "earlier today",
"last week", "a while back" — **establish the current date/time first and
compute the delta against the actual timestamp.** Get "now" from the injected
session date or by running `date` in a shell; never infer it.
**A container recreate or a fresh session is NOT a day boundary.** A pi-devbox
container is frequently restarted — often several times within the *same* day —
and each restart begins a new session with a fresh wake-up. Do not reason "new
session ⇒ last session was yesterday": two diary entries 90 minutes apart can
straddle a container recreate. The only authoritative clock is the timestamp on
the memory, not the session/container boundary.
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
about to write "yesterday" / "last week", subtract `now entry.timestamp` and
state the computed result. (Remember timestamps may be UTC while the wall clock
is local — reconcile the offset before computing the delta.) Note too that
session feeders can lag up to a week (see *Multi-harness palace*), so a recent
absence in `wing_conversations` is not proof nothing happened.
### Phase 2: Active Session (during work)
#### Search Before You Speak
@@ -294,6 +317,7 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
## Anti-Patterns
- **Don't guess when you can search.** If a question touches past work, search first.
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
@@ -91,6 +91,22 @@ are "command not found" there — you must spell out the underlying command.
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
differ, so a precomposed remote path *silently* fails to match on the host —
`scp … "mac:'~/Desktop/Skärmavbild ….png'"` returns *No such file or directory*
even though the file plainly exists. Sidestep the encoding entirely: let the
**remote shell expand a wildcard**, or list the directory first and copy the
exact name it prints.
```sh
# glob dodges the NFC/NFD mismatch (the remote shell matches the real bytes):
scp -F "$HOME/.ssh-local/config" "mac:~/Desktop/Sk*rmavbild*.png" ./
# or read the exact filename first, then copy that:
ssh -F "$HOME/.ssh-local/config" mac 'ls -1 ~/Desktop/*.png'
```
## 3. Reaching the Docker host and its LAN over SSH
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
@@ -0,0 +1,14 @@
# xterm-ghostty — alias of the maintained ncurses `ghostty` terminfo entry.
#
# Ghostty sets TERM=xterm-ghostty by default, but the ncurses terminfo
# database (Debian: ncurses-term) ships the entry under the name `ghostty`
# only — there is no `xterm-ghostty` alias, and no distro packages one. This
# thin alias makes xterm-ghostty resolve to the same upstream-maintained
# capability set, so SSH sessions from a Ghostty terminal work without
# vendoring Ghostty's full (Zig-generated) terminfo here.
#
# `use=ghostty` is resolved by `tic` at compile time against the base
# `ghostty` entry from ncurses-term (installed in Dockerfile.base before the
# compile step). Compiled with `tic -x`.
xterm-ghostty|Ghostty terminal emulator (xterm-ghostty alias),
use=ghostty,
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Gitea-accurate guard against the recurring "bash syntax under the default
# sh/dash shell" footgun (ed49b8d resolve-versions; b7197e8 promote-base-latest,
# run 418).
#
# 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 we hit (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
+34 -6
View File
@@ -6,6 +6,10 @@
# Verifies:
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
# - typst PDF engine for pandoc (Unreleased) — `pandoc --pdf-engine=typst`
# - non-modal editors nano + micro (alongside nvim)
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
# - pi-toolkit cloned at /opt/pi-toolkit
# - pi-extensions cloned at /opt/pi-extensions
@@ -15,6 +19,8 @@
# - mempalace bridge symlink present
# - settings.json bootstrapped
# - pi-fork + pi-observational-memory registered via `pi install`
# - pi-devbox-version command present + wraps the build manifest correctly
# (human, --json, --quiet)
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
# client bundle present + registered via `pi install`
# - image size within threshold
@@ -24,11 +30,15 @@ set -euo pipefail
IMAGE="${1:?usage: $0 <image>}"
PASS=0; FAIL=0
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
# imagemagick, yq, tealdeer, and a baked /etc/tmux.conf. Local arm64 build
# observed 3.20 GB. CI amd64 builds may differ slightly; threshold below
# carries +300 MB margin to absorb arch differences without false reds.
# Tighten in a follow-up release once amd64 actuals are observed in CI logs.
SIZE_THRESHOLD_MB=3500
# imagemagick, yq, tealdeer, a baked /etc/tmux.conf, and the non-modal
# editors nano + micro (~15 MB combined). v1.6.0 baked in agent-browser +
# Playwright Chromium (~291 MB net after dropping the unused headless-shell
# build), which lifted the baseline. CI amd64 actuals observed on run 512
# (v1.6.1): 3411 MB non-studio, 3574 MB studio. Threshold below carries
# ~225 MB margin above the studio number to absorb minor arch/build-cache
# differences and small future growth without false reds, while still
# catching an unexpected +GB regression.
SIZE_THRESHOLD_MB=3800
run() {
local label="$1"; local cmd="$2"
@@ -71,12 +81,20 @@ run "git" "git --version"
run "aws" "aws --version"
run "uv" "uv --version"
run "nvim" "nvim --version"
run "nano" "nano --version"
run "micro" "micro --version"
run "kitty-terminfo" "infocmp -x xterm-kitty >/dev/null 2>&1"
run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot ghostty st-256color; do infocmp -x "$t" >/dev/null 2>&1 || exit 1; done'
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
run "mempalace-mcp" "mempalace-mcp --help"
# v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version"
run "typst" "typst --version"
run "pandoc+typst PDF engine" "printf '# hi\n' | pandoc --pdf-engine=typst -o /tmp/_smoke.pdf - && test -s /tmp/_smoke.pdf; rm -f /tmp/_smoke.pdf"
run "graphviz (dot)" "dot -V"
run "imagemagick" "magick --version"
run "yq" "yq --version"
run "yq (mikefarah v4)" "yq --version | grep -qE 'mikefarah.*version v4'"
run "tldr (tealdeer)" "tldr --version"
run "socat" "socat -V"
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
@@ -146,6 +164,16 @@ run_expect "manifest records pi_version" \
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
run "manifest has no unresolved ('unknown') components" \
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
# pi-devbox-version wraps the manifest into a human-first command (this
# PR); verify the binary is present, executable, and both output modes work.
run "pi-devbox-version binary present + executable" \
"test -x /usr/local/bin/pi-devbox-version"
run_expect "pi-devbox-version human output shows release tag" \
"pi-devbox-version" "pi-devbox "
run_expect "pi-devbox-version --json round-trips the manifest" \
"pi-devbox-version --json" '"release_tag"'
run_expect "pi-devbox-version --quiet is a compact one-liner" \
"pi-devbox-version --quiet | wc -l" "1"
# OCI labels live in the image config, not the container fs — inspect them
# from the host docker rather than via `docker run`.
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)