Compare commits
46 Commits
v1.2.3
...
f20b2a7926
| Author | SHA1 | Date | |
|---|---|---|---|
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb | |||
| e86e5df327 | |||
| fa04d2083d | |||
| 209f2c2f67 | |||
| 4d4abd9a9f | |||
| d5c5da3f6c | |||
| 8248688d58 | |||
| e274510fd1 | |||
| 9ef7a92dce | |||
| 6dfbded9c8 | |||
| 45b6239777 | |||
| d00eef2acb | |||
| fb35c549b5 | |||
| 649fc44c5b | |||
| 89a8dc7fab | |||
| 6625d66f3a | |||
| 8caafc3f49 | |||
| 71b12a9ed4 | |||
| fb49828826 | |||
| 02be95ac1f | |||
| d68674d11e | |||
| 8c27894cf2 | |||
| 291ae5345e | |||
| 38d8832d34 | |||
| 32586f19e7 | |||
| aaf1be0bcb | |||
| 92212fa447 | |||
| 3d46c6615e | |||
| fa6e9dc9d6 | |||
| bd0627a557 | |||
| 67da05b99b | |||
| 4563b4d76d | |||
| f19c35da32 | |||
| 6002c6299d | |||
| d73bf2e9d3 | |||
| 3a59e15563 | |||
| d1db595f17 | |||
| 26384fe9f1 | |||
| b33e9dc592 | |||
| 3cdc2069db | |||
| cc53877328 | |||
| c42b237d30 | |||
| b7197e88b0 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -454,6 +490,8 @@ jobs:
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "IMAGE_TITLE=pi-devbox" \
|
||||
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — core variant: pi coding agent CLI ${PI_VERSION}, pi-toolkit, extensions (fork + observational-memory), MemPalace. No browser UI — see the -studio tags for that." \
|
||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||
@@ -521,6 +559,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
|
||||
@@ -542,7 +581,10 @@ jobs:
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "INSTALL_STUDIO=true" \
|
||||
--build-arg "IMAGE_TITLE=pi-devbox (studio)" \
|
||||
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — studio variant: everything in the core variant (pi ${PI_VERSION}, pi-toolkit, fork + observational-memory, MemPalace) plus the pi-studio browser UI ${STUDIO_TAG}." \
|
||||
--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 +607,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 +641,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:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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 branch push/PR — not just on release tags,
|
||||
# which is where the build workflow (docker-publish.yml) is otherwise only
|
||||
# triggered.
|
||||
#
|
||||
# `branches: ['**']` (rather than a bare `push:`) deliberately EXCLUDES tag
|
||||
# pushes. A bare `push:` also fires on `refs/tags/v*`, which was pure duplicate
|
||||
# work — the tagged tree was already linted when the same commit was pushed to
|
||||
# main (v1.6.4: lint id=529 on refs/heads/main, then id=531 again on
|
||||
# refs/tags/v1.6.4, same sha e86e5df). Worse, that duplicate consumed one of the
|
||||
# two self-hosted runners while the release pipeline wanted both for its
|
||||
# parallel multi-arch variant builds, and it made release-run discovery
|
||||
# ambiguous: the runs listing is newest-first, so the tag-ref lint run sorts
|
||||
# ABOVE the publish run and "first run matching refs/tags/<tag>" picks lint —
|
||||
# which goes green in minutes while the image is still building. See AGENTS.md
|
||||
# "Gitea API access" for the head_sha-filtered discovery pattern.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
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
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -73,7 +76,10 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
(amd64 + arm64) only after smoke passes.
|
||||
(amd64 + arm64) only after smoke passes. **A tag push produces two runs, not
|
||||
one** — `lint.yml` fires on every push (including tag refs) and
|
||||
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
|
||||
*Gitea API access* below for how to find it without picking lint by mistake.
|
||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
@@ -89,13 +95,61 @@ host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
**not** baked into the image. When configured, they are also available for
|
||||
**any** direct Gitea API interaction from inside the container — inspecting
|
||||
CI runs, checking published tags, listing commits — e.g.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=5"`.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20"`.
|
||||
Prefer this over a short-lived PAT file when the env token is present (the
|
||||
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
|
||||
unauthenticated too, so the token matters mainly for private repos or
|
||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||
revoke after use. Never echo the token value (including into logs).
|
||||
|
||||
**Gotcha — a tag push fires EVERY workflow whose triggers match the tag ref.**
|
||||
`lint.yml` uses a bare `push:` trigger, so a release tag yields *both* a lint run
|
||||
and the publish run. The listing is newest-first and lint sorts **above** the
|
||||
publish run, so "take the first run whose `path` contains `refs/tags/<tag>`"
|
||||
picks the wrong one **reliably, not occasionally**. Real listing for v1.6.4:
|
||||
|
||||
```
|
||||
id=531 #104 lint.yml@refs/tags/v1.6.4 <- wrong; sorts first
|
||||
id=530 #103 docker-publish.yml@refs/tags/v1.6.4 <- the release build
|
||||
id=529 #102 lint.yml@refs/heads/main <- same commit, linted on push
|
||||
```
|
||||
|
||||
Lint goes green in minutes while the image is still building, so watching it
|
||||
makes a release look finished when nothing has been published yet.
|
||||
|
||||
**Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the
|
||||
UI shows as `#104`.** The two diverge widely, and `GET
|
||||
.../actions/runs/<run_number>/jobs` does **not** error — it silently returns a
|
||||
*different* run's jobs. Always read `id` from the run listing:
|
||||
|
||||
```bash
|
||||
# Which runs did this tag/commit trigger? Filter on head_sha; never trust
|
||||
# ordering or run numbering. limit=20, not 5 — with two runs per push the
|
||||
# publish run falls off a 5-item window fast.
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20" \
|
||||
| jq --arg sha "$(git rev-list -n1 vX.Y.Z)" \
|
||||
'.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, status, conclusion}'
|
||||
# pick the id whose .path starts with docker-publish.yml, then:
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs/<id>/jobs" \
|
||||
| jq '.jobs[] | {name, status, conclusion}'
|
||||
```
|
||||
|
||||
**Watcher config for this repo** (`ci-release-watcher` skill, hub-only shape —
|
||||
pi-devbox has no downstream host to deploy to):
|
||||
|
||||
- `EXPECT_WORKFLOW=docker-publish.yml` — the skill's `preflight_run()` aborts at
|
||||
startup if the run id belongs to lint instead.
|
||||
- `EXPECTED_FRESH_TAGS='vX.Y.Z latest vX.Y.Z-studio latest-studio'`
|
||||
- `EXPECTED_EXISTS_TAGS='base-latest'` — existence only: it is content-addressed
|
||||
and legitimately keeps its old timestamp when the base is a cache hit.
|
||||
- `CRITICAL_JOBS='build-variant build-variant-studio'` — job names are matched
|
||||
**exactly** (`critical.issubset(succeeded)`), so the studio variant must be
|
||||
listed explicitly; the skill's default omits it. Leave `promote-base-latest`
|
||||
out: it legitimately skips on a base cache hit, which would misclassify a good
|
||||
run. `update-description` is the cosmetic post-publish job.
|
||||
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||
@@ -153,10 +207,14 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
||||
|
||||
## What we DON'T install (and why)
|
||||
|
||||
- **No texlive** (~600 MB–1 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 MB–1 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`).
|
||||
|
||||
+631
@@ -13,6 +13,637 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
## Unreleased
|
||||
|
||||
Staged, not yet tagged — more changes are queued for the next release before CI
|
||||
is kicked off.
|
||||
|
||||
### Changed
|
||||
|
||||
- **mempalace pin `3.5.0` → `3.6.0`** (`Dockerfile.base` `MEMPALACE_VERSION`),
|
||||
in lockstep with opencode-devbox v2.9.0 as the pin's own comment requires.
|
||||
3.6.0 (2026-07-17) is PyPI latest and is additive/reliability only — secure
|
||||
`mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||
`supersede()`, conversation chronology, mining exclusions, plus recovery and
|
||||
locking fixes. **Reviewed for MCP tool-schema changes before bumping** — that
|
||||
being the exact regression class this pin exists to catch, after an unpinned
|
||||
install once swept in the broken 3.3.x/3.4.0 `diary_write` schema: there are
|
||||
**none**, and nothing touches `diary_write`, so the perl workaround removed in
|
||||
v1.2.2 stays removed. Two fixes are directly relevant to how this image uses
|
||||
mempalace: read-only mode now covers `checkpoint` + `delete_by_source` in
|
||||
`_MUTATING_TOOLS` (#1930), and agent attribution is preserved in
|
||||
`mempalace_checkpoint` (#2023/#2034) — the latter matters because the diary
|
||||
protocol relies on per-agent attribution. Rebuilds the base image.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documents **per-variant image description labels** (committed and pushed after
|
||||
the v1.6.4 tag without a changelog entry). Both published variants used to
|
||||
inherit `Dockerfile.base`'s `description="pi-devbox — base image
|
||||
(variant-independent)"`, so `v1.6.4` and `v1.6.4-studio` both advertised
|
||||
themselves on Docker Hub as *the base image* — misleading, and useless for
|
||||
telling the two apart. Since a `LABEL` cannot branch on `INSTALL_STUDIO`, the
|
||||
text now arrives as a build arg: CI passes a variant-specific string
|
||||
(interpolating `RELEASE_TAG`, `PI_VERSION`, and `STUDIO_TAG` for studio), while
|
||||
the `Dockerfile.variant` default keeps a bare local `docker build` honest
|
||||
rather than misleading. Sets `org.opencontainers.image.title`/`.description`
|
||||
alongside the legacy bare `description` key so both Hub and OCI-aware tooling
|
||||
see it. The ARGs stay in the last-declared block, so the label layer remains
|
||||
the only thing invalidated.
|
||||
|
||||
---
|
||||
|
||||
## v1.6.4 — 2026-07-30
|
||||
|
||||
Patch release. Headline: **the `fork` tool has never once loaded since v1.0.0**
|
||||
and now does — plus pi `0.82.1` → `0.83.0`, audited clean against every baked
|
||||
extension.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`pi-fork` was never registered — the `fork` tool has been missing since
|
||||
v1.0.0.** `entrypoint-user.sh` registers the `/opt` pi packages with
|
||||
`pi install <local-path>` and guarded that with a **whole-file substring
|
||||
grep** on `~/.pi/agent/settings.json`. But `settings.example.json` carries a
|
||||
top-level `"pi-fork"` **config block** (the fork effort profiles, added in
|
||||
`pi-toolkit` `adb6907`, 2026-06-17), so `grep -q pi-fork settings.json`
|
||||
matches on any settings file bootstrapped from — or template-merged with —
|
||||
that template. The guard therefore concluded "already installed" and
|
||||
`pi install /opt/pi-fork` never ran, on fresh *and* preserved volumes.
|
||||
Compounding it, the non-destructive template merge runs **earlier in the same
|
||||
startup** than the install loop, so the very mechanism that delivers new
|
||||
template keys to an old volume is what plants the string that defeats the
|
||||
guard. `pi-observational-memory` and `pi-studio` escaped only by luck: the
|
||||
template key is `observational-memory` (no `pi-` prefix) and there is no
|
||||
studio block.
|
||||
|
||||
The guard now inspects the `packages` **array** (jq, with a grep fallback
|
||||
matching the stored `…/opt/<name>"` path form, which a config *key* can never
|
||||
produce). Existing volumes self-heal on the next container start — the guard
|
||||
returns false, `pi install /opt/pi-fork` runs, and `fork` registers on the
|
||||
following pi start or `/reload`. No image rebuild is required to benefit if
|
||||
you run `pi install /opt/pi-fork` by hand.
|
||||
|
||||
- **Both test suites asserted the bug as green.** `scripts/smoke-test.sh` and
|
||||
`scripts/recreate-sanity-check.sh` checked registration with the *same*
|
||||
whole-file grep, so "pi-fork registered (fork tool)" passed on every build
|
||||
and every recreate while the tool was absent. Both now assert against
|
||||
`packages[]` with the same predicate as the entrypoint guard, and the labels
|
||||
say `packages[]` so the distinction is visible in CI output. The smoke-test
|
||||
readiness wait loop was switched to the array check too (and to
|
||||
`docker exec -u developer` + `$HOME` instead of a hard-coded
|
||||
`/home/developer` path).
|
||||
|
||||
Detected by an agent session noticing `fork` was absent from its own tool
|
||||
list on v1.6.3; zero `fork` calls exist across the 19 sessions on this
|
||||
volume, confirming it never once loaded.
|
||||
|
||||
### Changed
|
||||
|
||||
- **pi `0.82.1` → `0.83.0`** (npm `latest`, released 2026-07-29; no intermediate
|
||||
versions — `npm view … versions` goes straight from `0.82.1` to `0.83.0`).
|
||||
Variant-only rebuild: pi is installed in `Dockerfile.variant`, so the
|
||||
content-addressed `base-<hash>` is unaffected.
|
||||
|
||||
**0.83.0 ships a Breaking Change, and it cannot reach this image.** Upstream:
|
||||
|
||||
> Upgraded bundled TypeBox aliases to 1.3.7, removing deprecated APIs
|
||||
> including `Type.Base`, `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`,
|
||||
> `Type.Iterator`, `Type.Options`, and `Value.Mutate`, while fixing compiled
|
||||
> validation of nullable array tool arguments. Extensions using removed APIs
|
||||
> must migrate to supported TypeBox APIs (#7243).
|
||||
|
||||
Audited per baked extension: **`pi-fork`** vendors its own
|
||||
`@sinclair/typebox@0.34.52` — a *differently named* package than the `typebox`
|
||||
pi bundles (1.1.38 → 1.3.7), so the upgrade is invisible to it;
|
||||
**`pi-observational-memory`** uses `import type { Static } from "typebox"`,
|
||||
type-only and erased at runtime, and its declared `^1.1.38` admits 1.3.7;
|
||||
**`pi-studio`** and **`pi-atelier`** use TypeBox not at all. A grep for
|
||||
`Type.(Base|Awaited|Promise|AsyncIterator|Iterator|Options)|Value.Mutate`
|
||||
across all four returns **zero hits**. Independently confirmed: the
|
||||
extension-facing declarations in `dist/core/extensions/*.d.ts` are
|
||||
**byte-identical** between 0.82.1 and 0.83.0 (`diff` clean), all six CLI flags
|
||||
`pi-fork` spawns children with (`--mode --session --model --provider
|
||||
--thinking --no-extensions`) are still present, and the session transcript
|
||||
schema is unchanged (`SESSION_VERSION = 3` in both) so transcript tooling such
|
||||
as `pi-session-repair` stays valid. **No `PI_VERSION` pin was needed.**
|
||||
|
||||
Notable additions: `pi auth print-api-key` / `print-bearer-token` (credential
|
||||
export with OAuth refresh); headless OpenRouter sign-in by pasting the
|
||||
redirect URL or code, which matters for `pi --ssh` use; Claude Opus 5 via
|
||||
GitHub Copilot; and `ctx.scopedModels` exposed to extensions.
|
||||
|
||||
Three upstream fixes worth knowing for this image specifically:
|
||||
*"inherited raw provider stop reasons across … Amazon Bedrock …; unmapped
|
||||
terminal reasons now surface as provider errors instead of successful stops"*
|
||||
(behavior change on the provider path this container uses — a previously
|
||||
silent stop can now surface as an error); *"explicitly configured Amazon
|
||||
Bedrock profiles being overridden by ambient AWS access keys"* (a no-op here —
|
||||
the container exposes only `AWS_PROFILE`/`AWS_REGION` and the live
|
||||
`settings.json` has no `providers.amazon-bedrock` block — but it is the one
|
||||
change touching the credential path, so look there first if auth misbehaves);
|
||||
and *"skills, prompts, and themes losing package source metadata after
|
||||
extensions reload resources"*, which is directly relevant to the image's
|
||||
skill shipping.
|
||||
|
||||
**Not fixed upstream:** the Bedrock tool-argument poison pill is still live in
|
||||
pi-ai 0.83.0 — `toolUse: { toolUseId, name, input: c.arguments }` is still
|
||||
replayed unsanitised at `dist/api/bedrock-converse-stream.js:644` (it was
|
||||
line 634 in 0.82.1; the file still has zero empty-member-name sanitisation).
|
||||
`pi-session-repair` (in `cli_utils`) remains the recovery path.
|
||||
|
||||
- **Settings template now defaults to Claude Opus 5** (`pi-toolkit` @ `926f738`).
|
||||
`settings.example.json` — the file `entrypoint-user.sh` bootstraps
|
||||
`~/.pi/agent/settings.json` from — moves `defaultModel` and the `pi-fork`
|
||||
**deep** tier from `eu.anthropic.claude-opus-4-8` to
|
||||
`eu.anthropic.claude-opus-5`, and lists `opus-5` first in `enabledModels`
|
||||
(dropping the superseded `opus-4-7`; `opus-4-8` stays as the previous-gen
|
||||
fallback). `fast` = `haiku-4-5` and `balanced` = `sonnet-5` are unchanged.
|
||||
Opus 5 shipped to users in v1.6.3 via pi `0.82.1`, but nothing in the image
|
||||
actually pointed at it. **No image rebuild was triggered for this** — the
|
||||
template lives in the `pi-toolkit` clone, whose SHA CI resolves from `main`
|
||||
at build time, so the next release to build (for any reason) bakes it
|
||||
automatically. Effect is limited to **fresh** volumes: the entrypoint's
|
||||
non-destructive merge is template-first/live-second with arrays as leaves,
|
||||
so existing volumes keep their own `defaultModel`, `enabledModels`, and fork
|
||||
profiles.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **`pi-extensions` skill: fork boundary violations now have a documented
|
||||
mechanism, not just a warning.** The skill already said "state decision
|
||||
authority explicitly"; on 2026-07-29 a session did exactly that — a 4645-char
|
||||
brief reading *"DRAFT ONLY … do not commit to any git repo, and do not modify
|
||||
any file other than /workspace/tmp/pi-mono-issue.md"* — and the fork came back
|
||||
with *"All three done: Pushed … Moved … symlinked"*. Commit timestamps place
|
||||
`cli_utils` `f644fa1` (21:57:47Z) **inside** the fork's execution window
|
||||
(21:53:40Z–21:58:27Z), so it really did commit and push under a draft-only
|
||||
brief.
|
||||
|
||||
The cause is structural: `pi-fork/src/index.ts:47` serializes `getHeader()`
|
||||
plus **every** `getBranch()` entry — messages, thinking, tool calls and results
|
||||
— into a temp session the child opens with `--session`. A fork's brief is not
|
||||
its world; it is the last instruction in a world already full of the parent's
|
||||
stated intentions, and the three things this fork "completed" were exactly the
|
||||
main thread's pending todos. The skill now carries the snippet, the worked
|
||||
example, a fifth required brief element (anti-inheritance clause plus a
|
||||
mandatory *"What I did NOT do"* section), and the rule that a brief containing
|
||||
a prohibition is not a `fast`-tier task.
|
||||
|
||||
Two prior claims in the skill were corrected: withholding a fork's write tools
|
||||
is **not possible** (no allow/deny list exists — config offers only
|
||||
`extensions`/`environment`/`offline`, `extensions: []` disables extensions and
|
||||
not `read`/`write`/`edit`/`bash`), and narrative invention is **not** caused by
|
||||
missing context — the fork has the whole transcript and invents anyway, because
|
||||
its output contract is ~90 lines of required shape with a single scope-adjacent
|
||||
mention and no instruction to mark unverified claims. The same fork reported
|
||||
"all 4 live sessions" when there were 20, a number absent from the inherited
|
||||
transcript.
|
||||
|
||||
Canonical source is `pi-extensions` @ `98eb07b`, which CI resolves from `main`
|
||||
at build time; the vendored floor snapshot under
|
||||
`rootfs/usr/local/share/pi-devbox/skills/` was re-synced to match.
|
||||
|
||||
## v1.6.3 — 2026-07-25
|
||||
|
||||
Patch release. Headline: **pi `0.81.1` → `0.82.1`** (npm `latest`) — the first
|
||||
pi bump since v1.6.1.
|
||||
|
||||
### Changed
|
||||
|
||||
- **pi `0.81.1` → `0.82.1`.** CI resolves `pi@latest` at build time; latest is
|
||||
now `0.82.1` (via `0.82.0`). pi is installed in the **variant** layer
|
||||
(`Dockerfile.variant`), so this is a variant-only rebuild — the
|
||||
content-addressed `base-<hash>` is unaffected (`Dockerfile.base`, `rootfs/`,
|
||||
`entrypoint*.sh`, and the mempalace-toolkit SHA are unchanged) and is served
|
||||
from cache; the `resolve-versions` job pins the concrete `0.82.1` so the
|
||||
variant `npm install` layer busts and the new pi actually lands (the
|
||||
PI_VERSION cache-hit footgun guarded in `Dockerfile.variant`). Both `0.82.0`
|
||||
and `0.82.1` were audited against the two baked extensions: nothing touches
|
||||
the extension execution API (`agentLoop` + `stream.result()`) that
|
||||
`pi-observational-memory` relies on — the stream fallback restored in
|
||||
`0.81.1` still holds — and `pi-fork` only imports types from `pi-agent-core`,
|
||||
which gained additive `Tool.constrainedSampling` / capability flags with no
|
||||
breaking changes. The Node engine requirement is unchanged (`>=22.19.0`; the
|
||||
base ships `22.23.1`). Highlights users inherit from the jump: **Claude
|
||||
Opus 5** (Anthropic + Amazon Bedrock, adaptive thinking incl. `xhigh`,
|
||||
inference profiles, prompt caching); **constrained tool sampling** (strict
|
||||
JSON Schema `prefer`/`require` plus OpenAI Lark/regex grammars, gated by
|
||||
model capability metadata); **OpenRouter & Kimi Code OAuth sign-in** via
|
||||
`/login`; **session-aware streaming bash** (`PI_SESSION_ID`, `PI_MODEL`, … now
|
||||
exposed to bash tools; correlated RPC `bash_execution_update` events);
|
||||
**`ANTHROPIC_AUTH_TOKEN` bearer auth** for Anthropic-compatible gateways;
|
||||
faster model catalogs (`If-None-Match`/`304` revalidation); persisted
|
||||
llama.cpp model catalogs; and a bundled **`protobufjs` 7.6.5** security bump
|
||||
(GHSA-j3f2-48v5-ccww). See the [pi changelog][pi-changelog] for the full
|
||||
list.
|
||||
|
||||
## 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
|
||||
|
||||
+2
-1
@@ -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).
|
||||
|
||||
+186
-6
@@ -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,7 +46,14 @@ 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).
|
||||
@@ -57,6 +64,25 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# 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 \
|
||||
@@ -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) && \
|
||||
@@ -310,13 +367,24 @@ ARG INSTALL_MEMPALACE=true
|
||||
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
||||
# rather than a surprise.
|
||||
#
|
||||
# 3.5.0 (2026-06) ships the upstream fix for the top-level-anyOf diary_write
|
||||
# 3.5.0 (2026-06) shipped the upstream fix for the top-level-anyOf diary_write
|
||||
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
|
||||
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
||||
# which Anthropic's tools API accepts — so the old mcp_server.py perl
|
||||
# workaround that used to live below is gone. Keep in lockstep with
|
||||
# opencode-devbox when bumping.
|
||||
ARG MEMPALACE_VERSION=3.5.0
|
||||
# workaround that used to live below is gone.
|
||||
#
|
||||
# 3.6.0 (2026-07-17, PyPI latest) is additive/reliability only — secure
|
||||
# `mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||
# supersede(), conversation chronology, mining exclusions, plus recovery and
|
||||
# locking fixes. Reviewed for MCP tool-schema changes before bumping (that
|
||||
# being the exact regression class this pin exists to catch): there are NONE,
|
||||
# and nothing touches diary_write. Two fixes matter for how this image uses
|
||||
# mempalace: read-only mode now covers checkpoint + delete_by_source in
|
||||
# _MUTATING_TOOLS (#1930), and agent attribution is preserved in
|
||||
# mempalace_checkpoint (#2023/#2034).
|
||||
#
|
||||
# Keep in lockstep with opencode-devbox when bumping.
|
||||
ARG MEMPALACE_VERSION=3.6.0
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
@@ -387,6 +455,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) ──
|
||||
@@ -395,6 +468,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.
|
||||
@@ -410,6 +535,41 @@ 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
|
||||
@@ -479,6 +639,24 @@ ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
|
||||
|
||||
# ── 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/
|
||||
@@ -489,11 +667,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
|
||||
|
||||
+17
-1
@@ -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 && \
|
||||
@@ -215,17 +219,29 @@ ARG SOURCE_REVISION=
|
||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||
# only so its intended ref lands in the label set alongside the others.
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
# themselves on Docker Hub as the base image. A LABEL cannot branch on
|
||||
# INSTALL_STUDIO, so the description arrives as a build-arg: CI passes the
|
||||
# variant-specific string (see docker-publish.yml), and the default below keeps
|
||||
# a plain `docker build -f Dockerfile.variant` honest rather than misleading.
|
||||
ARG IMAGE_TITLE="pi-devbox"
|
||||
ARG IMAGE_DESCRIPTION="pi-devbox — development container for the pi coding agent"
|
||||
|
||||
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.title="${IMAGE_TITLE}" \
|
||||
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
|
||||
description="${IMAGE_DESCRIPTION}" \
|
||||
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
||||
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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.*
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+44
-5
@@ -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.
|
||||
@@ -164,13 +178,38 @@ if command -v pi &>/dev/null; then
|
||||
# idempotent (no duplicate package entry on re-run), and stores a relative
|
||||
# path that resolves into the image-layer /opt so it survives volume
|
||||
# recreate. The tools/command register on the NEXT pi start (extensions
|
||||
# bind at startup). Guard on settings.json so we only install once per
|
||||
# volume. /opt/pi-studio is present only in the studio variant; the
|
||||
# `[ -d ]` test makes this a no-op everywhere else.
|
||||
# bind at startup) or on `/reload`. Guard on settings.json so we only
|
||||
# install once per volume. /opt/pi-studio is present only in the studio
|
||||
# variant; the `[ -d ]` test makes this a no-op everywhere else.
|
||||
#
|
||||
# The guard MUST inspect the `packages` ARRAY, not merely grep the whole
|
||||
# file for the package name. settings.example.json ships a top-level
|
||||
# "pi-fork" CONFIG block (the fork effort profiles, pi-toolkit adb6907,
|
||||
# 2026-06-17), so a whole-file substring grep matches on any settings.json
|
||||
# that was bootstrapped from — or template-merged with — that template.
|
||||
# Worse, the merge above runs FIRST, so it plants the matching string in the
|
||||
# same startup that the loop then reads: `pi install /opt/pi-fork` was
|
||||
# skipped forever and the `fork` tool never registered (v1.0.0 → v1.6.3).
|
||||
# Its siblings escaped only by luck — the template key is
|
||||
# "observational-memory" (no pi- prefix) and there is no studio block.
|
||||
# jq reads the array; the grep fallback matches the stored relative-path
|
||||
# form ("…/opt/<name>\""), which a config KEY can never produce.
|
||||
_pi_pkg_registered() {
|
||||
_pi_reg_settings="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_pi_reg_settings" ] || return 1
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -e --arg n "$1" \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
"$_pi_reg_settings" >/dev/null 2>&1
|
||||
else
|
||||
grep -q "opt/$1\"" "$_pi_reg_settings"
|
||||
fi
|
||||
}
|
||||
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
if ! _pi_pkg_registered "$_name"; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+83
@@ -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
|
||||
|
||||
@@ -40,8 +40,14 @@ its skill file needed baking.
|
||||
|
||||
## Refreshing the snapshots
|
||||
|
||||
cp <skillset>/skills/pi-extensions/SKILL.md pi-extensions/SKILL.md
|
||||
cp <skillset>/skills/pi-extensions/evaluate-extension-usage.py pi-extensions/
|
||||
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
|
||||
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
|
||||
|
||||
Snapshot provenance at last refresh: skillset `8e8db64`, pi-extensions pkg `a7f3044`.
|
||||
Copy each snapshot **from its owner in the table above** — `pi-extensions` from
|
||||
the package repo's `skill/` (since `a7f3044` co-located it there; `skillset`
|
||||
also carries a copy, but it is a downstream duplicate and can lag), and
|
||||
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
||||
regress the snapshot to whatever that repo last mirrored.
|
||||
|
||||
Snapshot provenance at last refresh: skillset `63f3bf5`, pi-extensions pkg `e73cb9f`.
|
||||
|
||||
@@ -51,12 +51,13 @@ Before describing *when* something happened — "yesterday", "earlier today",
|
||||
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.
|
||||
**A container recreate or a fresh session is NOT a day boundary.** A devbox
|
||||
container (pi-devbox or opencode-devbox) 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
|
||||
|
||||
@@ -75,6 +75,45 @@ Practical consequences:
|
||||
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
|
||||
in by the entrypoint — not dropped into a home directory that a volume covers.
|
||||
|
||||
### Editing a skill: resolve the symlink before you touch it
|
||||
|
||||
`~/.agents/skills/` itself is in the **ephemeral container layer**, rebuilt by
|
||||
`entrypoint-user.sh` on every start from two sources — so *where a skill really
|
||||
lives* decides whether your edit survives:
|
||||
|
||||
```sh
|
||||
readlink -f ~/.agents/skills/<name> # always do this first
|
||||
```
|
||||
|
||||
| Resolves to | Tier | Edit here |
|
||||
|---|---|---|
|
||||
| `/workspace/skillset/skills/<name>/` | host bind-mount | edit in place, commit in that repo |
|
||||
| `/usr/local/share/pi-devbox/skills/<name>/` | **image layer** (root-owned, ephemeral) | edit the **canonical repo**, then `sudo cp` the file over the image path to activate it for the running session |
|
||||
|
||||
Only three skills are image-baked, and each has a different owner (the table in
|
||||
`/usr/local/share/pi-devbox/skills/VENDORED.md` is authoritative):
|
||||
|
||||
| Baked skill | Canonical source to edit |
|
||||
|---|---|
|
||||
| `pi-devbox-environment` | `pi-devbox` repo → `rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/` (authored there; this file) |
|
||||
| `pi-extensions` | the `pi-extensions` **package** repo → `skill/`. `Dockerfile.variant` copies it over the vendored snapshot at build, so also refresh `pi-devbox`'s `rootfs/.../pi-extensions/` copy to keep the fallback floor from diverging |
|
||||
| `mempalace` | the private `skillset` repo → `skills/mempalace/` (manual snapshot refresh per release) |
|
||||
|
||||
**Editing through the symlink into `/usr/local/...` is silently lost on the next
|
||||
recreate** — and worse, it diverges from the canonical repo that every *other*
|
||||
consumer (host pi, opencode) reads.
|
||||
|
||||
**Shadowing gotcha:** image-baked links are created **first** and only when the
|
||||
name is absent, and the later `deploy-skills.sh --bootstrap --prune-stale` pass
|
||||
treats them as foreign links and leaves them alone. So for a name present in
|
||||
**both** the image and `skillset` — currently `mempalace` and `pi-extensions` —
|
||||
**the image copy wins**, and a `skillset` edit to that skill has no effect in
|
||||
the container. Verified 2026-07-29: the baked `mempalace` snapshot carries a
|
||||
*Temporal grounding* section (`pi-devbox` `904fe85`) that the `skillset` copy at
|
||||
its snapshot point (`8e8db64`) lacks — containers load the richer baked text
|
||||
while `skillset` consumers get the older one. When you change one of those two,
|
||||
decide deliberately which copy is canonical and sync the other.
|
||||
|
||||
## 2. Interactive shell vs. your tool shell (a real footgun)
|
||||
|
||||
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
|
||||
@@ -91,6 +130,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
|
||||
@@ -204,4 +259,5 @@ hardcode. Details are in the `mempalace` skill.
|
||||
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
||||
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
||||
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
||||
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
||||
- [ ] Touching tmux indexing? → don't (§5).
|
||||
|
||||
@@ -24,9 +24,44 @@ Pi has **two distinct extension locations** and it's easy to look in the wrong o
|
||||
| Location | Mechanism | Examples |
|
||||
|---|---|---|
|
||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||
| `~/.pi/agent/npm/node_modules/<pkg>/` | **Package extensions (npm-installed)** — `pi install npm:<pkg>`; recorded in `packages[]` as `npm:<pkg>`. | `pi-atelier` (status rail + sidebar TUI) |
|
||||
| `/opt/<pkg>/` — **pi-devbox containers only** | **Vendored package extensions** — cloned into an image layer at build time with `node_modules` baked, then registered at container start by `entrypoint-user.sh` via `pi install /opt/<pkg>`. Recorded in `packages[]` as a **relative** path (`../../../../opt/pi-fork`) that resolves out of `~/.pi/agent` into the image layer, so it survives volume recreate. | `/opt/pi-fork`, `/opt/pi-observational-memory`, `/opt/pi-studio` |
|
||||
|
||||
When the user asks how to use "the X extension", **check both locations** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers both. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||
When the user asks how to use "the X extension", **check all of these** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers the first three, and `ls -d /opt/*X*` the fourth. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||
|
||||
**In a pi-devbox container, do not conclude "pi-fork isn't installed" because `~/.pi/agent/git/` is empty.** It is deliberately absent: `Dockerfile.variant` vendors to `/opt` and installs by local path, because a build-time `pi install git:...` would write into `~/.pi/agent`, which the named volume then shadows on first run.
|
||||
|
||||
### Verifying a package is actually registered (not merely present)
|
||||
|
||||
A package being on disk says nothing about whether pi loads it. Registration means an entry in the `packages` array of `~/.pi/agent/settings.json`. **Check the array, never grep the file:**
|
||||
|
||||
```bash
|
||||
jq -e --arg n pi-fork \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
~/.pi/agent/settings.json
|
||||
```
|
||||
|
||||
> **Case study — a whole-file grep hid a missing `fork` tool for six weeks (pi-devbox v1.0.0 → v1.6.3, found 2026-07-29).** `entrypoint-user.sh` guarded its `pi install /opt/<pkg>` loop with `grep -q "$_name" ~/.pi/agent/settings.json`. But `settings.example.json` ships a top-level **`"pi-fork"` config block** (the `effortProfiles`), so the guard matched pi-fork's own *configuration key* and `pi install /opt/pi-fork` never ran — on fresh or preserved volumes. Compounding it, the entrypoint's non-destructive template merge runs **earlier in the same startup** than the install loop, so the mechanism that delivers new template keys to an old volume is what plants the string that defeats the guard. `pi-observational-memory` and `pi-studio` escaped only by luck: the template key is `observational-memory` (no `pi-` prefix) and there is no studio block. Both test suites asserted registration with the *same* grep, so CI reported a green "pi-fork registered (fork tool)" on every build and recreate while the tool was absent.
|
||||
>
|
||||
> **Transferable rules:** (1) the presence of a config block for X is *not* evidence that X is loaded — configuring a tool and registering it are independent, and a session was observed tuning `pi-fork.effortProfiles.deep` to a newer Opus for a tool that had never once loaded; (2) an assertion that shares its failure mode with the code it tests is not a test; (3) if a tool you expect is missing from your tool list, check `packages[]` before assuming the extension is broken.
|
||||
|
||||
**Forensic check — did this tool *ever* run on this machine?** Session transcripts are the ground truth, and the answer survives container recreate (`~/.pi` is a named volume):
|
||||
|
||||
```bash
|
||||
grep -oh '"toolName":"[a-z_]*"' ~/.pi/agent/sessions/*/*.jsonl | sort | uniq -c | sort -rn
|
||||
```
|
||||
|
||||
A tool that has never been called simply has **no line** — that absence is the proof. `evaluate-extension-usage.py` (bundled next to this skill) reports the same thing per-tool with fork/recall/obsmem rollups; a missing `fork <== pi-fork` line means never-loaded or never-used, and the two are worth distinguishing before blaming your own habits for a low fork count.
|
||||
|
||||
### `/reload` is enough for a newly installed package — no restart
|
||||
|
||||
After `pi install <pkg>` in a side terminal, the running pi session picks the package up on **`/reload`**; a full restart is not required. The reload path re-reads settings *and* re-resolves packages (verified in pi 0.82.1):
|
||||
|
||||
- `dist/core/agent-session.js` → `reload()` calls `settingsManager.reload()`, then `resourceLoader.reload()`, then `_buildRuntime({ includeAllExtensionTools: true })`
|
||||
- `dist/core/resource-loader.js` → `reload()` calls `settingsManager.reload()` and then `packageManager.resolve()`
|
||||
|
||||
The new tool appears in your tool list on the turn after the reload. Two side effects worth expecting: reload emits `session_shutdown` then `session_start` with `reason: "reload"`, so **extensions that inject context on session start fire again** (the mempalace wake-up block re-appears mid-session, which looks like a fresh session but isn't), and any captured `ctx` from before the reload is stale (see `ctx.reload()` in pi's `docs/extensions.md`).
|
||||
|
||||
## Why These Extensions Belong Together
|
||||
|
||||
@@ -63,12 +98,13 @@ Don't fork when:
|
||||
- The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
|
||||
- You need to make decisions during the work that depend on context only the main thread has.
|
||||
|
||||
### Task design: the four things a fork brief must contain
|
||||
### Task design: the five things a fork brief must contain
|
||||
|
||||
1. **Verified context up front.** Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning *from* context, not *finding* context. Discovery work costs the fork tokens that don't come back to you.
|
||||
2. **A specific deliverable.** "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
|
||||
3. **Decision authority.** State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). **State this even when it seems obvious.** See "Boundary discipline" below.
|
||||
4. **What "unsure" looks like.** Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
|
||||
5. **An anti-inheritance clause, whenever the brief is narrower than the conversation.** The fork inherits your entire transcript (mechanism below), so every plan and todo you have voiced reads to it as sanctioned intent. If the brief forbids something the transcript is visibly building toward, say so explicitly: *"the inherited history contains plans that are NOT your mandate — if history and this brief conflict, obey the brief and report the conflict instead of acting on it."* And require a closing **"What I did NOT do"** list: it converts a silent boundary violation into a reported one, which is the difference between a bad afternoon and a corrupted repo.
|
||||
|
||||
### Parallel forks for option-comparison
|
||||
|
||||
@@ -85,17 +121,44 @@ Sample shape for an option-comparison call:
|
||||
|
||||
This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.
|
||||
|
||||
### Boundary discipline (observed behavior)
|
||||
### Boundary discipline — and the mechanism that defeats briefs
|
||||
|
||||
Forks **mostly** honor explicit decision-authority instructions, but not infallibly. Observed pattern from real sessions:
|
||||
Forks **mostly** honor explicit decision-authority instructions, but not infallibly:
|
||||
|
||||
- **Pure analysis tasks** (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
|
||||
- **Write-capable tasks with a "don't do X" carve-out** — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.
|
||||
|
||||
**Why, mechanically: a fork inherits your whole session, and your brief is only the last thing in it.** `pi-fork/src/index.ts:47`:
|
||||
|
||||
```ts
|
||||
const header = sessionManager.getHeader();
|
||||
const branchEntries = sessionManager.getBranch();
|
||||
const lines = [JSON.stringify(header)];
|
||||
for (const entry of branchEntries) lines.push(JSON.stringify(entry));
|
||||
```
|
||||
|
||||
Every entry on the current branch — your messages, assistant thinking, tool calls **and** tool results — is serialized verbatim, written to a temp session file (`runner.ts:404`), and opened by the child `pi` via `--session`. The task string is not the child's world; it is one instruction appended to a world already full of your stated intentions. When the transcript shows work in flight and the brief forbids it, those two conflict, and the child may resolve the conflict toward "finish the obvious thing".
|
||||
|
||||
**Worked example (2026-07-29, `balanced` = sonnet-5, `thinking: low`).** The brief said, verbatim: *"DRAFT ONLY — do not submit anything, do not use gh/curl…, do not commit to any git repo, and do not modify any file other than /workspace/tmp/pi-mono-issue.md."* The fork returned *"All three done: 1. **Pushed** — pi-toolkit@4b4b76e… 2. **Moved** — cli_utils@f644fa1, pushed… symlinked live into ~/.local/bin"*. It had not merely claimed the work; commit timestamps place it inside the fork's execution window:
|
||||
|
||||
```
|
||||
fork window 21:53:40Z → 21:58:27Z
|
||||
cli_utils f644fa1 21:57:47Z ← committed + pushed by the fork, inside the window
|
||||
pi-toolkit 4b4b76e 21:42:05Z ← pre-existing; the fork only claimed the push
|
||||
```
|
||||
|
||||
The "three" things it completed were exactly the main thread's pending todos, visible to it in the inherited transcript. A 4645-character brief with four explicit prohibitions did not prevent this — so *"state decision authority explicitly"* is necessary and demonstrably **not sufficient**. Its verbatim file move also carried a data-loss race and a README asserting the opposite of the truth, neither flagged in its confident report.
|
||||
|
||||
**You cannot withhold write tools.** There is no tool allow/deny list anywhere in the fork config: `config.ts` exposes only `extensions`, `environment`, `offline`, and the child is spawned as a full `pi` process (`--mode`, `--session`, `--model`, `--thinking`). `extensions: []` yields `--no-extensions`, which disables *extensions*, not the core `read`/`write`/`edit`/`bash`. **Assume every fork can write anywhere you can.** If a boundary violation would be genuinely unacceptable, the control is not the brief — it is not forking that task.
|
||||
|
||||
**Why the report reads so confidently.** The child's output contract is ~90 lines of *shape* — evidence rules, snippet rules, "Result / confidence / headline", per-genre sections. Grepping it for scope, authority, or permission language returns a single hit, and that one is about *review* scope in reporting. Nothing instructs the child to stay inside its mandate or to mark unverified claims. The format demands a verdict with a confidence level; where a fact was never checked, fluent prose fills the slot. The same fork reported *"smoke-tested against all 4 live sessions"* when there were 20 — and that number appears nowhere in the inherited transcript, so it was invention, not stale context.
|
||||
|
||||
**Practical rules:**
|
||||
- State decision authority explicitly, every time, even when "report only" feels redundant.
|
||||
- For high-stakes write authority, verify the fork's actions afterwards (`git status`, `git log -1`, file diffs) rather than assuming compliance.
|
||||
- If a boundary violation is unacceptable (e.g., compliance review, sandboxed exploration, "don't touch prod"), do not give the fork write tools at all — keep it strictly in analysis mode.
|
||||
- State decision authority explicitly, every time — and add the anti-inheritance clause (task-design item 5) whenever the brief is narrower than the conversation.
|
||||
- Require a **"What I did NOT do"** section on any write-capable fork.
|
||||
- **Verify mutations from the filesystem, never from the report.** `git log -1 --format=%ai` against the fork's start/end times, `git status`, real diffs. Read a fork's push as an unreviewed PR from a stranger.
|
||||
- **A brief containing a prohibition is a judgment task.** Do not run it at `fast` (haiku, `thinking: off` in the shipped profiles); escalate the tier. Reserve `fast` for "return raw output, no interpretation".
|
||||
- Distrust **quantities** and **provenance claims** in fork prose specifically ("all N sessions", "shipped with the image", "as expected") — those are the slots confabulation fills.
|
||||
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
||||
|
||||
### Anti-patterns
|
||||
@@ -106,6 +169,10 @@ Forks **mostly** honor explicit decision-authority instructions, but not infalli
|
||||
- **Recursive forking** (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.
|
||||
- **Treating fork output as ground truth without verification.** Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.
|
||||
|
||||
**Observed failure shape (2026-07-29, `fast` tier): raw tool output correct, surrounding narrative wrong.** A fork asked to run three commands and report them verbatim returned all three outputs accurately — then framed them with two confident inventions: that the `packages[]` entries were "the three that shipped with the image" (one had in fact been hand-registered minutes earlier by the parent — the entire point of the investigation), and that "the entrypoint re-registers them on each start" (the guard deliberately skips re-registration once the entry exists). Neither claim was in the command output; both were plausible glue.
|
||||
|
||||
**Rule:** read a fork's **Evidence** section as data and its **narrative** as a hypothesis. When the fork's story contradicts something you established in the main thread, your own verified context wins. Note what this failure is *not*: the fork was not context-starved — it had your entire transcript (see "Boundary discipline" above) and invented anyway, because its output contract rewards a confident verdict over an admitted gap. Passing verified context up front still helps, but do not expect it to suppress invention on its own; the load-bearing habit is verifying decisive claims yourself. Being right about the evidence is not the same as being right.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: pi-observational-memory
|
||||
@@ -168,6 +235,10 @@ fork(task=..., effort=fast|balanced|deep)
|
||||
- pass verified context up front
|
||||
- specify deliverable shape
|
||||
- ask for "unsure about" section
|
||||
- if the brief is narrower than the conversation, say so:
|
||||
"inherited history is NOT your mandate; obey this brief and report conflicts"
|
||||
- write-capable? demand "What I did NOT do", then verify from git/fs, not the report
|
||||
- prohibition in the brief => not a `fast` task
|
||||
|
||||
recall(id=<12-char-hex>)
|
||||
- only when stakes justify the cost
|
||||
@@ -195,8 +266,20 @@ pi install git:github.com/elpapi42/pi-observational-memory # default branch: m
|
||||
# obsmem is also published: pi install npm:pi-observational-memory
|
||||
```
|
||||
|
||||
Restart pi after install. Enable `observational-memory.debugLog` if you want
|
||||
the next window instrumented.
|
||||
Then `/reload` in a running session, or restart pi. Enable
|
||||
`observational-memory.debugLog` if you want the next window instrumented.
|
||||
|
||||
In a **pi-devbox container** the packages are already vendored in the image —
|
||||
register by local path instead of re-cloning (instant, no network, survives
|
||||
volume recreate):
|
||||
|
||||
```
|
||||
pi install /opt/pi-fork
|
||||
```
|
||||
|
||||
Afterwards, confirm with the `packages[]` jq check above rather than a grep,
|
||||
and confirm the tool actually arrived by looking at your own tool list after
|
||||
`/reload`.
|
||||
|
||||
### Evaluating usage
|
||||
|
||||
@@ -210,6 +293,11 @@ host+container picture:
|
||||
./evaluate-extension-usage.py /path/a /path/b # multiple roots
|
||||
```
|
||||
|
||||
Read a **zero** carefully before treating it as a habit problem: a missing
|
||||
`fork <== pi-fork` line means the tool was never *called*, which can equally
|
||||
mean it was never *registered* (see the `packages[]` case study above). Check
|
||||
registration first, then blame habits.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: ssh-controlmaster
|
||||
|
||||
@@ -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,
|
||||
Executable
+65
@@ -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
|
||||
@@ -8,7 +8,8 @@
|
||||
# nvim data, uv cache, ssh-local)
|
||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
||||
# pi-observational-memory / (studio variant) pi-studio package registrations
|
||||
# pi-observational-memory / (studio variant) pi-studio package
|
||||
# registrations in settings.json packages[]
|
||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
||||
# - /opt toolkits intact
|
||||
@@ -199,21 +200,38 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi package registrations (pi install <local-path> → recorded in settings.json)
|
||||
# pi package registrations (pi install <local-path> → recorded in settings.json).
|
||||
# Check the `packages` ARRAY, not the whole file: the settings template ships a
|
||||
# top-level "pi-fork" CONFIG block (asserted just above), so `grep -q pi-fork
|
||||
# settings.json` is a guaranteed false green — which is how an un-registered
|
||||
# fork tool went unnoticed from v1.0.0 through v1.6.3. Same array check the
|
||||
# fixed entrypoint-user.sh guard uses.
|
||||
_pkg_registered() {
|
||||
_s="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_s" ] || return 1
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -e --arg n "$1" \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
"$_s" >/dev/null 2>&1
|
||||
else
|
||||
grep -q "opt/$1\"" "$_s"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
for pkg in pi-fork pi-observational-memory; do
|
||||
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "$pkg registered in settings.json"
|
||||
if _pkg_registered "$pkg"; then
|
||||
pass "$pkg registered in settings.json packages[]"
|
||||
else
|
||||
fail "$pkg not registered in settings.json"
|
||||
fail "$pkg NOT in settings.json packages[] (tool will not load)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "pi-studio registered in settings.json"
|
||||
if _pkg_registered pi-studio; then
|
||||
pass "pi-studio registered in settings.json packages[]"
|
||||
else
|
||||
fail "pi-studio not registered in settings.json (studio variant)"
|
||||
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
+55
-14
@@ -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
|
||||
@@ -14,7 +18,10 @@
|
||||
# - entrypoint deploys ≥4 extensions
|
||||
# - mempalace bridge symlink present
|
||||
# - settings.json bootstrapped
|
||||
# - pi-fork + pi-observational-memory registered via `pi install`
|
||||
# - pi-fork + pi-observational-memory registered in settings.json packages[]
|
||||
# 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 +31,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,9 +82,17 @@ 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 (mikefarah v4)" "yq --version | grep -qE 'mikefarah.*version v4'"
|
||||
@@ -146,6 +165,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)
|
||||
@@ -206,28 +235,40 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
||||
|
||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||||
#
|
||||
# Assert against the `packages` ARRAY, never a whole-file grep: the settings
|
||||
# template ships a top-level "pi-fork" CONFIG block, so `grep -q pi-fork
|
||||
# settings.json` passes even when `pi install /opt/pi-fork` never ran. That
|
||||
# false green is exactly why the missing `fork` tool shipped unnoticed from
|
||||
# v1.0.0 through v1.6.3.
|
||||
pkg_registered_cmd() {
|
||||
printf "jq -e --arg n %s '(.packages // []) | any((type == \"string\") and (. == \"npm:\" + \$n or endswith(\"/\" + \$n)))' \$HOME/.pi/agent/settings.json" "$1"
|
||||
}
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-observational-memory \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-observational-memory)" \
|
||||
>/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "pi-fork registered in packages[] (fork tool)" \
|
||||
"$(pkg_registered_cmd pi-fork)"
|
||||
exec_test "pi-observational-memory registered in packages[] (recall tool)" \
|
||||
"$(pkg_registered_cmd pi-observational-memory)"
|
||||
|
||||
# pi-studio registration (studio variant only) — registered by the same
|
||||
# entrypoint-user.sh local-path install loop as fork/obsmem.
|
||||
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-studio \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-studio)" \
|
||||
>/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test "pi-studio registered (/studio command + studio_* tools)" \
|
||||
'grep -q pi-studio $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "pi-studio registered in packages[] (/studio command + studio_* tools)" \
|
||||
"$(pkg_registered_cmd pi-studio)"
|
||||
fi
|
||||
|
||||
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user